我的需求是需要有一个能够构建出 dotnet 的 AOT 包的环境,要求这个环境能解决 glibc 兼容依赖的问题,能打出来 x64 和 arm64 的 AOT 的包,且能够运行 gitlab runner 对接自动构建

需求

以下是我列举的需求

  • 支持制作能在 UOS 系统和麒麟系统上运行的包
  • 支持制作出来的包是 AOT 版本的
  • 可以使用 gitlab runner 对接自动构建

  • 能够支持 CPF 、 Avalonia 、 UNO 、MAUI 、 GTKSystem.Windows.Forms 等 UI 框架

开始之前必须说明的是,对于 dotnet 应用来说,如果不需要 AOT 的话,完全可以在 Windows 上构建出其他 Linux 系统和其他平台适用的应用。仅仅只是在 AOT 下,强依赖平台构建时,才有需要在对应的系统平台构建

制作方法

我制作的 docker 的 Dockerfile 是基于 debian:buster-slim 打上负载的

FROM debian:buster-slim

为了提升一点拉取速度,我换成国内的源,使用的是阿里的源

RUN rm /etc/apt/sources.list
COPY sources.list /etc/apt/sources.list
RUN apt-get update

这里的 sources.list 的代码是从 debian镜像_debian下载地址_debian安装教程-阿里巴巴开源镜像站 抄的,代码如下

deb http://mirrors.aliyun.com/debian/ buster main non-free contrib
deb-src http://mirrors.aliyun.com/debian/ buster main non-free contrib
deb http://mirrors.aliyun.com/debian-security buster/updates main
deb-src http://mirrors.aliyun.com/debian-security buster/updates main
deb http://mirrors.aliyun.com/debian/ buster-updates main non-free contrib
deb-src http://mirrors.aliyun.com/debian/ buster-updates main non-free contrib

为了交叉构建,同时构建出 ARM64 的 AOT 的 dotnet 应用,我根据 Cross-compilation - .NET - Microsoft Learn 的文档安装上必要的负载

RUN dpkg --add-architecture arm64
RUN apt update

RUN apt-get install libicu-dev -y
RUN apt-get install libssl-dev -y
RUN apt-get install wget -y
RUN apt-get install clang llvm -y
RUN apt-get install gcc-aarch64-linux-gnu -y
RUN apt-get install binutils-aarch64-linux-gnu -y
RUN apt-get install zlib1g-dev -y
RUN apt-get install zlib1g-dev:arm64 -y

为了方便调试和对接 gitlab runner 我还加上了 git 和 vim 工具

RUN apt-get install vim -y
RUN apt-get install git -y

RUN apt-get clean

到这一步,就完成了 docker image 里面的基础部分了,现在的 Dockerfile 的代码如下

FROM debian:buster-slim
WORKDIR /root

RUN rm /etc/apt/sources.list
COPY sources.list /etc/apt/sources.list
RUN apt-get update

RUN dpkg --add-architecture arm64
RUN apt update

RUN apt-get install libicu-dev -y
RUN apt-get install libssl-dev -y
RUN apt-get install wget -y
RUN apt-get install clang llvm -y
RUN apt-get install gcc-aarch64-linux-gnu -y
RUN apt-get install binutils-aarch64-linux-gnu -y
RUN apt-get install zlib1g-dev -y
RUN apt-get install zlib1g-dev:arm64 -y

RUN apt-get install vim -y
RUN apt-get install git -y

RUN apt-get clean

接着到 dotnet 官网 下载 dotnet 8 和 dotnet 6 的 sdk 压缩包,本文这里使用的是自己解压缩的方式。换成命令方式安装也可以,只是命令方式拉取的速度可能不如先下载压缩包的方式,且下载压缩包可以方便多次重新构建,在 Dockerfile 不断需要修改时,使用压缩包可以省去多次修改之后的重新构建时的拉取时间

本文这里采用的是下载压缩包的方式,下载到 dotnet-sdk-6.0.421-linux-x64.tar.gz 和 dotnet-sdk-8.0.204-linux-x64.tar.gz 这两个压缩包。如果大家下载失败,或者没有网速的话,可以邮件给我,让我用网盘发给你。一般情况下在国内都能拉取成功,因为微软帮忙提供了全球 CDN 了,下载速度在我这里还是很快的。下载 dotnet 6 版本仅仅只是为了让我的构建工具正常工作而已,属于可选项

下载完成 dotnet 的压缩包,即可使用 Dockerfile 的 ADD 命令将压缩包解压缩到 docker image 里的某个文件夹里面,如下面代码

WORKDIR /root
ADD dotnet-sdk-6.0.421-linux-x64.tar.gz ./dotnet
ADD dotnet-sdk-8.0.204-linux-x64.tar.gz ./dotnet

解压缩完成之后,配置环境变量等,让全局可以使用 dotnet 命令

ENV DOTNET_ROOT="/root/dotnet"
ENV PATH="${PATH}:${DOTNET_ROOT}:${DOTNET_ROOT}/tools"
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
RUN ln -s /root/dotnet/dotnet /usr/bin/dotnet

完成以上步骤之后,一个特别简单的 dotnet 构建 Dockerfile 已经完成了,接下来一步则是配置 gitlab runner 的步骤。我将参考 gitlab runner 官方安装文档 进行配置,只是过程稍微取巧

先根据 Install GitLab Runner manually on GNU/Linux - GitLab 提供的方法,拉取 GitLab Runner 的二进制压缩包,本文这里是需要下载 Linux x86-64 版本,当前的下载链接是 https://s3.dualstack.us-east-1.amazonaws.com/gitlab-runner-downloads/latest/binaries/gitlab-runner-linux-amd64

下载链接可能会有变更,还请大家重新参考 Install GitLab Runner manually on GNU/Linux - GitLab 文档,找到更新的下载 Linux x86-64 版本的下载地址

完成下载之后,通过 COPY 命令拷贝到 docker image 里

COPY gitlab-runner-linux-amd64 /usr/share/gitlab/gitlab-runner

RUN chmod +x /usr/share/gitlab/gitlab-runner

完成以上步骤之后需要对 GitLab Runner 进行配置。本文这里采用取巧的方式,即先将 GitLab Runner 运行起来,配置完成之后,存放配置文件,再将配置文件打入到 docker image 里面,后续就只需启动 docker image 即可

具体的步骤是先将当前的 Dockerfile 构建且运行。我这里使用的是 podman 工具,如果大家使用的是 docker desktop 的话,只需将 podman 命令换成 docker 命令即可,其他参数相同

// 先 cd 到 Dockerfile 所在的文件夹,再执行以下命令
podman build -t t1 .

当前的 Dockerfile 文件的代码如下

FROM debian:buster-slim
WORKDIR /root

RUN rm /etc/apt/sources.list
COPY sources.list /etc/apt/sources.list
RUN apt-get update

RUN dpkg --add-architecture arm64
RUN apt update

RUN apt-get install libicu-dev -y
RUN apt-get install libssl-dev -y
RUN apt-get install wget -y
RUN apt-get install clang llvm -y
RUN apt-get install gcc-aarch64-linux-gnu -y
RUN apt-get install binutils-aarch64-linux-gnu -y
RUN apt-get install zlib1g-dev -y
RUN apt-get install zlib1g-dev:arm64 -y

RUN apt-get install vim -y
RUN apt-get install git -y

RUN apt-get clean

ADD dotnet-sdk-6.0.421-linux-x64.tar.gz ./dotnet
ADD dotnet-sdk-8.0.204-linux-x64.tar.gz ./dotnet
ENV DOTNET_ROOT="/root/dotnet"
ENV PATH="${PATH}:${DOTNET_ROOT}:${DOTNET_ROOT}/tools"
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
RUN ln -s /root/dotnet/dotnet /usr/bin/dotnet

COPY gitlab-runner-linux-amd64 /usr/share/gitlab/gitlab-runner

RUN chmod +x /usr/share/gitlab/gitlab-runner

再将打包好的 docker image 运行,运行时记得挂载上文件夹,用于将 docker 里面的文件传输到主机

// 提前创建好 C 盘的 lindexi 的 wsl 文件夹,你换成自己的文件夹也可以
podman run -i -t -v /mnt/c/lindexi/wsl:/etc/gitlab-runner t1

以上代码的 /mnt/c/lindexi/wsl 是我自己的 C:\lindexi\wsl 文件夹,这是我提前建立好的空文件夹。大家换成自己的文件夹也可以,如果用 docker desktop 的话,需要看一下是否运行在 wsl 上,如果不在的话,也许需要换成 Windows 下的路径表示方法,相信这一步难不倒大家的

进入之后,即可使用 /usr/share/gitlab/gitlab-runner 命令进行注册,具体的注册步骤如下

本文使用注册 GitLab 组 作为例子,注册单个项目的步骤也类似,详细请参阅 https://docs.gitlab.com/runner/configuration/ 文档。先进入到 GitLab 的 组 的 Runner 配置界面里面,点击 New group runner 按钮

点击之后,进入配置界面。由于这是一个特殊的构建方式,我推荐写上 gitlab 的 runner tag 项,我这里写的是 debian-dotnet-docker 标记。这里的标记需要和 git 的 tag 区分哦,这是两个完全不相同的东西

点击 Create runner 按钮,即可进入到创建配置命令界面,拷贝其配置命令参数,如我这里的是

gitlab-runner register
  --url https://gitlab.lindexi.com
  --token glrt-HbCpfssbPSFqR_xVtxLX

于是我在运行起来的 docker 命令行里面输入以下命令用于注册

/usr/share/gitlab/gitlab-runner register --url https://gitlab.lindexi.com --token glrt-HbCpfssbPSFqR_xVtxLX

输入之后,一路都是回车下一步,除了执行命令部分可选使用 shell 之外。完成之后再使用 /usr/share/gitlab/gitlab-runner run 命令运行起来试试,如果能够运行成功,且在 gitlab 的 runner 页面里面能够看到运行起来的 runner 则证明成功。否则还请自行调试哈,我也不熟悉

完成之后即可愉快退出 docker 环境,此时即可在挂载到 /etc/gitlab-runner 的文件夹里面,即本文的 C:\lindexi\wsl 文件夹里面看到配置文件,一般是 config.toml 文件

在上述步骤完成之后,咱可以取出来挂载的文件夹,如我这里的 C:\lindexi\wsl 文件夹,将其拷贝到 Dockerfile 文件所在的文件夹里面,用于编写 Dockerfile 拷贝到 /etc/gitlab-runner 文件夹里面,如此制作出来的 docker image 将会带上已经注册的 gitlab runner 信息

COPY wsl /etc/gitlab-runner

接着再执行安装命令,以及设置入口为 gitlab-runner run 即可

RUN /usr/share/gitlab/gitlab-runner install --user=root --working-directory=/root/.local/share/gitlab

ENTRYPOINT ["/usr/share/gitlab/gitlab-runner", "run"]

完成以后的 Dockerfile 文件如下

FROM debian:buster-slim
WORKDIR /root

RUN rm /etc/apt/sources.list
COPY sources.list /etc/apt/sources.list
RUN apt-get update

RUN dpkg --add-architecture arm64
RUN apt update

RUN apt-get install libicu-dev -y
RUN apt-get install libssl-dev -y
RUN apt-get install wget -y
RUN apt-get install clang llvm -y
RUN apt-get install gcc-aarch64-linux-gnu -y
RUN apt-get install binutils-aarch64-linux-gnu -y
RUN apt-get install zlib1g-dev -y
RUN apt-get install zlib1g-dev:arm64 -y

RUN apt-get install vim -y
RUN apt-get install git -y

RUN apt-get clean

ADD dotnet-sdk-6.0.421-linux-x64.tar.gz ./dotnet
ADD dotnet-sdk-8.0.204-linux-x64.tar.gz ./dotnet
ENV DOTNET_ROOT="/root/dotnet"
ENV PATH="${PATH}:${DOTNET_ROOT}:${DOTNET_ROOT}/tools"
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
RUN ln -s /root/dotnet/dotnet /usr/bin/dotnet

COPY gitlab-runner-linux-amd64 /usr/share/gitlab/gitlab-runner

RUN chmod +x /usr/share/gitlab/gitlab-runner

COPY wsl /etc/gitlab-runner

RUN /usr/share/gitlab/gitlab-runner install --user=root --working-directory=/root/.local/share/gitlab

ENTRYPOINT ["/usr/share/gitlab/gitlab-runner", "run"]

RUN mkdir /root/build
WORKDIR /root/build

尝试构建此 Dockerfile 文件

// 先 cd 到 Dockerfile 所在的文件夹,再执行以下命令
podman build -t t1 .

接着挂载到后台运行

podman container run -v nuget_global:/root/.nuget/packages -v nuget_cache:/root/.local/share/NuGet -v gitlabrunner:/root/.local/share/gitlab -d t1

以上命令的 -v nuget_global:/root/.nuget/packages -v nuget_cache:/root/.local/share/NuGet -v gitlabrunner:/root/.local/share/gitlab 属于可选的参数,用来挂载 nuget 缓存等内容,解决 docker 每次重启都会丢失缓存文件,提升重启 docker 之后的构建速度,减少重复拉取 nuget 包

完成以上步骤之后,就已经完成了制作一个能构建 dotnet AOT 的 gitlab ruuner 的 Debian docker 镜像

可以尝试在自己的项目里面,编写 .gitlab-ci.yml 文件,指定到这个运行起来的 docker image 上运行,以下是我的测试使用的 .gitlab-ci.yml 文件代码

stages:
  - build

BuildLinuxX64InDocker:
  stage: build
  script:
    - 'dotnet run publish -p:PublishAot=true -c Release -r linux-x64'
  tags:
    - docker-uos

BuildLinuxArm64InDocker:
  stage: build
  script:
    - 'dotnet run publish -p:PublishAot=true -c Release -r linux-arm64'
  tags:
    - docker-uos 

如果能够构建成功,且构建出 linux-x64 和 linux-arm64 的 dotnet 可执行文件,则表示成功。否则还请自行根据输出的错误信息修复

踩坑记录

为什么不在 WSL 里面构建

核心原因是 WSL 里面的 glibc 版本过于新,使用 ldd --version 命令可以看到的输出如下

ldd (GNU libc) 2.36

而麒麟的 Desktop-V10-SP1 版本的 glibc 是 2.31 版本,更惨的 UOS 20.1050.11068.102 版本的 glibc 是 2.28 版本,都低于 WSL 里面的版本

这就意味着在 WSL 里面构建出来的应用将无法在以上的两个系统上运行

这就是为什么使用 debian:buster-slim 的原因。当前我拉取的 debian:buster-slim 的 docker image id 是 6d0d34a48ee1 的版本。通过 cat /etc/debian_version 可以看到在此版本里面带的是 debian 10.13 版本

再通过 ldd --version 命令行获取的 glibc 版本信息,可以看到带的是 2.28 版本,刚好与 UOS 20.1050.11068.102 版本的 glibc 版本相同,低于麒麟的 Desktop-V10-SP1 的 glibc 版本

因此在此 debian:buster-slim 里面 AOT 构建出来的包可以同时在 UOS 20.1050.11068.102 和麒麟的 Desktop-V10-SP1 版本运行

debian buster-backports Release does not have a Release file

开始国内源使用了阿里的,结果遇到以下错误内容

E: The repository 'http://mirrors.aliyun.com/debian buster-backports Release' does not have a Release file.
Error: building at STEP "RUN apt update": while running runtime: exit status 100

重新参考了 替换docker容器默认的debian镜像 - OrcHome 博客,结果依然配置失败。核心原因是配置的版本不正确

我当前使用的是 debian 是 10.13 版本,需要根据 debian镜像_debian下载地址_debian安装教程-阿里巴巴开源镜像站 教程文档,更新对应的 debian 10.x (buster) 的配置,即如下内容

debian 10.x (buster)

编辑/etc/apt/sources.list文件(需要使用sudo), 在文件最前面添加以下条目(操作前请做好相应备份)

deb https://mirrors.aliyun.com/debian/ buster main non-free contrib
deb-src https://mirrors.aliyun.com/debian/ buster main non-free contrib
deb https://mirrors.aliyun.com/debian-security buster/updates main
deb-src https://mirrors.aliyun.com/debian-security buster/updates main
deb https://mirrors.aliyun.com/debian/ buster-updates main non-free contrib
deb-src https://mirrors.aliyun.com/debian/ buster-updates main non-free contrib

我是如何知道我当前的 debian 版本的?我通过运行镜像,输入 cat /etc/debian_version 命令获取到版本

No system certificates available

完成配置阿里的源,遇到以下的错误内容

W: https://mirrors.aliyun.com/debian/dists/buster/InRelease: No system certificates available. Try installing ca-certificates.

原因是 ca-certificates 没有提前安装,可以在切换为国内源之前,安装好。安装方法可参阅 修复 Debian 安装 dotnet 失败 depends on ca-certificates

由于我这里不需要关注安全性问题,更简单的方法是将 https 全部更换为 http 即可

安装 dotnet tool 失败

执行任何的 dotnet tool install 都会提示如下错误

Unhandled exception: System.IO.FileNotFoundException: Unable to find the specified file.
   at Interop.Sys.GetCwdHelper(Byte* ptr, Int32 bufferSize)
   at Interop.Sys.GetCwd()
   at Microsoft.DotNet.Cli.ToolPackage.ToolPackageDownloader..ctor(IToolPackageStore store, String runtimeJsonPathForTests)
   at Microsoft.DotNet.ToolPackage.ToolPackageFactory.CreateToolPackageStoresAndDownloader(Nullable`1 nonGlobalLocation, IEnumerable`1 additionalRestoreArguments)
   at Microsoft.DotNet.Tools.Tool.Update.ToolUpdateLocalCommand..ctor(ParseResult parseResult, IToolPackageDownloader toolPackageDownloader, IToolManifestFinder toolManifestFinder, IToolManifestEditor toolManifestEditor, ILocalToolsResolverCache localToolsResolverCache, IReporter reporter)
   at Microsoft.DotNet.Tools.Tool.Update.ToolUpdateCommand..ctor(ParseResult result, IReporter reporter, ToolUpdateGlobalOrToolPathCommand toolUpdateGlobalOrToolPathCommand, ToolUpdateLocalCommand toolUpdateLocalCommand)
   at Microsoft.DotNet.Cli.ToolUpdateCommandParser.<>c.<ConstructCommand>b__14_0(ParseResult parseResult)
   at System.CommandLine.Invocation.InvocationPipeline.Invoke(ParseResult parseResult)
   at Microsoft.DotNet.Cli.Program.ProcessArgs(String[] args, TimeSpan startupTime, ITelemetry telemetryClient)

暂时没有找到可用方法,只能绕路

我在 windows 下将所需工具下载下来,然后通过拷贝进入的方式即可完全安装

当然,在本文例子里面,我没有加上我所使用的工具

在 gitlab 构建脚本找不到 dotnet 命令

在命令行里面,可以使用 dotnet 命令,但是在 .gitlab-ci.yml 文件里面编写的脚本找不到 dotnet 命令

加上如下配置到 Dockerfile 即可

RUN ln -s /root/dotnet/dotnet /usr/bin/dotnet

以上命令是对 dotnet 建立链接,如此即可让全局可以使用 dotnet 命令

为什么使用 podman 工具

原因是在 windows 下的 docker desktop 是收费的,于是我用平替的 podman 工具

详细请看 如何在 Windows 使用 Podman Desktop 取代 Docker Desktop - 张善友 - 博客园

还原速度过慢

由于 docker 本身是不带持久化存储文件,只有通过挂载本机存储的方式,才能让 docker 里面的文件持久化存放

还原速度过慢的问题,是因为初始化时没有任何的 NuGet 缓存,导致需要大量拉取,从而导致拉取过慢

根据 How to manage the global packages, cache, temp folders in NuGet - Microsoft Learn 官方文档说明,获取到默认的缓存路径,使用如下命令将缓存路径挂载到本机

-v nuget_global:/root/.nuget/packages -v nuget_cache:/root/.local/share/NuGet

我这里挂载写的是相对路径,如 nuget_global 等路径,相对路径在 podman 下将会存放到 wsl 里面,详细请看 在 windows 上运行的 podman 默认的挂载相对路径是什么

另一个方法就是搭建本地的 NuGet 源。比如我用 BaGet 搭建一个本地 NuGet 源,然后设置自动 Mirror 其他 NuGet 源,配置如下

{
    ...

    "Mirror": 
    {
        "Enabled":  true,
        "PackageSource": "https://api.nuget.org/v3/index.json"
    },

    ...
}

详细请看 https://github.com/loic-sharma/BaGet

为什么代码仓库路径不挂载

如上述还原速度过慢原因,由于 docker 本身是不带持久化存储文件,只有通过挂载本机存储的方式,才能让 docker 里面的文件持久化存放。有些伙伴认为将代码仓库路径也进行本机挂载,可以减少拉取代码仓库的时间。实际上这么做可能带来的后果是开启多 docker 容器时,出现构建过程中的相互影响问题

拉取代码仓库时,大部分时间都是拉取内网的,且只影响容器的重启后的首次拉取。因此挂在代码仓库不是必要的

挂载代码仓库可能受到 Windows 自带杀毒影响,导致 llvm-objcopy 这一步失败,大概的错误信息如下

llvm-objcopy: failed to open xx.dbg Input/output error.

/root/.nuget/packages/microsoft.dotnet.ilcompiler/8.0.4/build/Microsoft.NETCore.Native.targets(379,5): error MSB3073: The command ""llvm-objcopy" --only-keep-debug xx xx exited with code 1.

解决方法是要么不挂载,要么在 Windows 自带杀毒加白名单

如何使用交叉编译

由于我缺少 ARM64 的机器,或者准确来说我缺少一台可以撑住构建的有性能的 ARM64 的机器,我期望能够在原有的 linux-x64 机器上构建出 ARM64 的应用。于是我就需要使用到交叉编译技术,通过此技术我就可以在 linux-x64 的机器上构建出 linux-arm64 的应用

参考 Cross-compilation - .NET - Microsoft Learn 的文档安装上必要的负载,如下面的 docker 代码,即可在 debian 的 x64 系统上构建出 ARM64 的 dotnet 的 AOT 应用

RUN dpkg --add-architecture arm64
RUN apt update

RUN apt-get install libicu-dev -y
RUN apt-get install libssl-dev -y
RUN apt-get install wget -y
RUN apt-get install clang llvm -y
RUN apt-get install gcc-aarch64-linux-gnu -y
RUN apt-get install binutils-aarch64-linux-gnu -y
RUN apt-get install zlib1g-dev -y
RUN apt-get install zlib1g-dev:arm64 -y

在进行 dotnet 发布时,将在 dotnet 里面自动根据 -r 参数自动执行交叉编译,如下面命令

dotnet publish -p:PublishAot=true -c Release -r linux-arm64

相关文档请看 runtime/docs/workflow/building/libraries/cross-building.md at main · dotnet/runtimeruntime/docs/workflow/building/coreclr/cross-building.md at main · dotnet/runtime

为什么不使用 gitlab-runner start 命令

因为实际测试 gitlab-runner start 之后没有真的执行,如下面代码

RUN /usr/share/gitlab/gitlab-runner start

添加之后运行 docker image 也不会有 gitlab runner 上线

如果换成下面的代码,则启动 docker image 之后立刻退出

ENTRYPOINT ["/usr/share/gitlab/gitlab-runner", "start"]

实际测试只有以下代码符合预期

ENTRYPOINT ["/usr/share/gitlab/gitlab-runner", "run"]

找不到 runner 机器或找错

先调查是否 dotnet 配置 Gitlab 的 CI 找不到 Runner 或找错的可能原因 提及的问题

排除之后,记得查看是否带上了 tags 和 runner 在 gitlab 上配置正确且相同的

参考文档

更多国产 UOS 系统开发相关,欢迎加入 810052083 群交流


本文会经常更新,请阅读原文: https://blog.lindexi.com/post/%E5%88%B6%E4%BD%9C%E4%B8%80%E4%B8%AA%E8%83%BD%E6%9E%84%E5%BB%BA-dotnet-AOT-%E7%9A%84-gitlab-runner-%E7%9A%84-Debian-docker-%E9%95%9C%E5%83%8F.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者收藏我的博客导航

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系

微软最具价值专家


无盈利,不卖课,做纯粹的技术博客

以下是广告时间

推荐关注 Edi.Wang 的公众号

欢迎进入 Eleven 老师组建的 .NET 社区

以上广告全是友情推广,无盈利