最近在帮团队把几个老的 .NET 解决方案往现代 SDK 风格迁移,越干越觉得,.NET 这些年构建和发布方式的变化,真的被很多人低估了。前两篇聊了聊整体趋势和发布模型,这篇作为系列第三篇,我打算把重点放在最实在的工程化改造上——构建产物怎么收拢、包版本怎么管得住、构建期怎么提前发现问题、发布镜像怎么少写脚本。全程不绕弯子,直接讲配置、讲思路、讲踩坑。这轮的“革新”跟以前不太一样。以前大家关注的是 C# 语法新特性、运行时性能,而构建发布体系一直处于“能跑就行”的状态。但从 .NET 开始把Artifacts输出布局、集中包管理、NuGet 安全审计、容器镜像发布这些能力直接内建进 SDK 之后,构建这件事终于从“脚本拼凑”走向了“工程化标准件”。这篇文章适合正在维护多项目解决方案的人,也适合那些被 CI 脚本和发布脚本折磨过的朋友——看完可以直接照着改。1. 内容整体设计与思路拆解1.1 从最让人崩溃的日常说起先说个我见得最多的场景:一个解决方案里几十个项目,每个项目都有自己的bin、obj目录,编译完之后整个仓库遍地都是构建产物。打包的时候要么让 CI 逐个项目遍历拷贝,要么写一个几百行的 MSBuild Target 去汇总,出问题的时候日志翻得人眼冒金星。版本管理更是一笔糊涂账——有的项目引用Newtonsoft.Json 12.0.3,有的引用13.0.1,升级版本靠全局搜索替换,替换完构建崩了还得一个个排查是谁不兼容。这套路我太熟悉了,因为我自己维护的老项目就是这么过来的。后来切换到一个新解决方案,才真正体会到 SDK 内置的构建治理能力有多省心。核心思路其实不复杂,就是“把约定变成默认”:不再靠每个人自觉遵守规范,而是让工具链强制统一输出路径、统一版本来源、统一安全检查、统一发布入口。说白了,就像给整个解决方案立了一套规矩,谁都不能例外。1.2 功能的组合拳,而不是单点补丁有人可能会问:Artifacts输出布局和集中包管理不是两个独立功能吗?为什么放在一起讲?我的理解是,这轮革新的价值不在于某个单一功能,而在于它们组合之后形成的整体体验。比如:Artifacts让所有项目的编译输出统一进artifacts/bin和artifacts/obj,CI 拿到干净的制品目录去归档;集中包管理让版本声明收敛到一个文件,和artifacts目录一起放进仓库根目录,整个解决方案的“状态”一目了然;NuGet 审计在构建期扫依赖漏洞,把安全问题从上线后前置到编译阶段;容器镜像发布则让dotnet publish直接输出镜像,省掉手工维护Dockerfile的重复劳动。四件事一环扣一环,本质上是在重新定义“一个 .NET 项目从源码到制品”的标准链路。我之前在某个模拟项目 X 上做改造时,把这几项一次性全落地,改造完之后最大的感受不是“功能变多了”,而是“脚本变少了”。删掉了几百行 PowerShell 和 MSBuild 自定义 Target,构建、测试、发布都回归到标准命令,团队成员上手成本也低了很多。1.3 这套方案适合什么场景说句实在话,并不是所有项目都适合马上全套改造。如果你是单项目、甚至只是一个类库,那Artifacts输出布局的意义不大,反而多了一层抽象;如果项目里用了大量自定义构建目标,并且这些目标对路径有硬编码,改到统一输出目录后可能有一批兼容性工作要做。最适合的场景是:多项目解决方案,并且 CI 有明确的制品归档需求;团队规模超过三个人,包版本管理靠口头约定已经出过问题;发布流程走容器化,而且不想每个服务维护一份Dockerfile。在这些场景下,新方案带来的收益远远大于改造成本。2. 核心细节解析与实操要点2.1 统一构建输出:Artifacts Output LayoutArtifacts输出的原理很简单:通过设置UseArtifactsOutput为true,SDK 会把所有项目的BaseOutputPath和BaseIntermediateOutputPath重定向到解决方案根目录下的artifacts文件夹。具体来说,编译产物统一进artifacts/bin/<项目名>/<配置>/<目标框架>/,中间文件统一进artifacts/obj/<项目名>/,不再留在各自的项目目录里。配置方式极其简单,在解决方案根目录放一个Directory.Build.props,写上:<Project> <PropertyGroup> <UseArtifactsOutput>true</UseArtifactsOutput> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> </PropertyGroup> </Project>我特别强调ArtifactsPath要基于MSBuildThisFileDirectory,而不是写死一个相对路径。因为 MSBuild 中相对路径的基准目录会随执行上下文变化,用MSBuildThisFileDirectory才能保证无论从哪里调用构建,输出的落点都是同一个地方。这是我踩过的坑之一,后面会细说。这套机制对 CI 的收益最大。以前 CI 要遍历每个项目目录把bin/Release找出来,现在一行通配符就能拿到全部产物,比如artifacts/bin/**/publish/*.zip。清理也简单,删除整个artifacts目录即可,不会留下零散的obj目录把仓库弄脏。注意一点:这个功能只对使用 SDK 风格的项目有效。如果解决方案里还残留着旧的非 SDK 风格项目格式,需要先迁移,否则设置会被忽略。迁移本身工作量不大,但确实得做。2.2 集中包管理:Central Package Management集中包管理(我后面简称 CPM)解决的痛点非常明确:包版本散落在各个项目文件里,升级成本高,一致性难保证。它的做法是把“版本”和“引用”拆开——在根目录的Directory.Packages.props里集中声明每个包的版本,项目文件里只写引用了哪个包,不写版本号。具体配置是这样,先在根目录建Directory.Packages.props:<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> </PropertyGroup> <ItemGroup> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageVersion Include="FluentValidation" Version="11.9.0" /> </ItemGroup> </Project>然后在项目文件里这样引用:<ItemGroup> <PackageReference Include="Newtonsoft.Json" /> <PackageReference Include="Microsoft.Extensions.Hosting" /> </ItemGroup>项目文件如果没有显式写Version,构建时就会自动从Directory.Packages.props里的PackageVersion取值。这样做的最大好处是,整个解决方案的依赖版本只有一个出处,想升级某个包,改一处即可。CentralPackageTransitivePinningEnabled是我强烈建议开启的属性。它的作用是让间接依赖(传递依赖)的版本也受中央版本文件约束,避免出现“我明明把包 A 锁到了 1.0,结果包 B 依赖的包 A 还是 1.1 传递上来了”这种失控情况。尤其是在大型解决方案里,没有这个 pinning,间接依赖漂移的事故概率非常高。2.3 构建期安全审计:NuGet AuditNuGet 审计是我觉得这轮革新里“最不起眼但最实用”的功能。以前查依赖漏洞,要么靠上线前的安全扫描,要么靠人工关注公告,等发现漏洞的时候,项目通常已经带着漏洞跑了好几个月了。NuGet Audit 把这件事直接拉到了构建期。它不需要额外配置,SDK 默认会在dotnet restore和dotnet build时检查和项目相关的包有没有已知漏洞,如果有,会输出类似NU1900系列的错误或警告。默认情况下是警告,但我建议团队内部把它提升为错误,强制阻断带漏洞的依赖进入主分支:<PropertyGroup> <NuGetAuditMode>all</NuGetAuditMode> <WarningsAsErrors>$(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsAsErrors> </PropertyGroup>NuGetAuditMode有两个值,direct只检查直接引用的包,all连传递依赖一起检查。我一般用all,因为间接依赖里的漏洞往往更隐蔽。不过也要有心理准备,设为all之后第一次构建可能冒出一堆警告,因为很多老项目里传递依赖的版本确实过时了。这时候不用慌,顺着警告的线索升级依赖即可,这是个渐进的过程,不是一次性大换血。3. 实操过程与核心环节实现3.1 改造前的项目面貌我拿一个典型的模拟项目 X 举例,它长这样:/解决方案根目录 /src /App.Api (Web API 项目) /App.Core (领域逻辑类库) /App.Infrastructure (数据访问类库) /tests /App.Api.Tests (接口测试项目) /scripts build.ps1 (自定义构建脚本) package.ps1 (打包脚本) /deploy Dockerfile (手工维护的镜像文件)改造前的问题很明显:每个项目都有自己的bin和obj,build.ps1里有一段二三十行的逻辑专门去各个目录收集 DLL;Dockerfile里COPY的路径依赖项目名和配置名,换一个项目就要改一遍;App.Infrastructure引用的Newtonsoft.Json版本和App.Api不一致,接口返回的 JSON 序列化行为还不一样。改造的思路就是一步步把这些手工活交给 SDK 的默认机制。3.2 构建侧三步改造第一步,在根目录创建Directory.Build.props,开启Artifacts输出。这一个文件的改动,整个解决方案的构建输出立即统一到artifacts目录下,所有项目共用一套目录规范。第二步,创建Directory.Packages.props,把各项目文件里散落的Version属性全部收拢。这里有个操作细节:先别急着一口气改完所有项目,最好先把根文件建好,然后逐个项目把PackageReference的Version去掉,每去掉一批就dotnet build一次。为什么?因为有的包可能只被某个项目引用,版本声明不在你的记忆范围内,靠编译报错来找漏网之鱼反而更精确。第三步,在 CI 或本地验证:dotnet restore ./Xxx.sln dotnet build ./Xxx.sln -c Release --no-restore构建结束后,产物会整齐落在artifacts/bin下,不会在项目目录产生新的bin和obj。我建议顺手把原来项目里的bin、obj目录全部删除,确保新旧两套机制不混合。如果不删,MSBuild 可能会优先读取旧目录里的中间文件,造成各种诡异的构建缓存问题。3.3 发布侧:容器镜像一键发布老方案里的Dockerfile长什么样,用过的朋友都知道。要处理基础镜像、工作目录、COPY顺序、dotnet restore和dotnet publish分段,还要小心 NuGet 源的还原缓存。每个服务都要写一份类似的,改起来就是个体力活。新方案是让 SDK 直接支持容器镜像发布。你只需要在项目文件里声明镜像信息:<PropertyGroup> <ContainerImageName>my-service</ContainerImageName> <ContainerImageTag>1.0.0</ContainerImageTag> <PublishProfile>DefaultContainer</PublishProfile> </PropertyGroup>然后执行:dotnet publish ./src/App.Api/App.Api.csproj -c Release -r linux-x64 --self-contained false -p:PublishProfile=DefaultContainerSDK 会自动完成镜像构建和推送,你不需要写 Docketfile,也不需要本地安装容器命令行工具。对于只需要“发布出一个能跑的镜像”的大多数 Web 服务来说,这套方式干净利落,少维护一份文件就少一份出错的可能。有人会担心灵活性不够。我的看法是:如果你的镜像需求复杂到需要多层自定义脚本、特殊基础镜像、复杂的健康检查参数,那你可以继续保留Dockerfile写自定义逻辑;但绝大多数内部服务根本用不到那些功能,用默认容器发布反而更省事。我的习惯是“默认用内置,特殊再绕行”。3.4 把整条链路串进 CI构建和发布都标准化之后,CI 脚本会变得非常简单。以常见的发布流程为例,大致是:# 还原、构建、跑测试 dotnet restore ./Xxx.sln dotnet build ./Xxx.sln -c Release --no-restore dotnet test ./Xxx.sln -c Release --no-build --logger "trx;LogFileName=results.trx" # 发布 Web 项目,直接输出容器镜像并推送到镜像仓库 dotnet publish ./src/App.Api/App.Api.csproj -c Release -r linux-x64 \ -p:PublishProfile=DefaultContainer \ -p:ContainerRegistry=registry.example.com \ -p:ContainerRepository=team-a/app-api注意,--no-build这个参数很重要。它确保测试和发布都复用同一份编译产物,不会出现“构建一次,发布又编译一次”的时间浪费。配合Artifacts统一输出,整个流水线的中间产物路径是确定的,脚本里再也不需要写“遍历目录找 DLL”这种东西了。4. 常见问题与排查技巧实录4.1 CPM 的报错速查集中包管理接入后,最常见的几个报错我列一下,都是实际会遇到的:NU1004:项目里写了PackageVersion但没开ManagePackageVersionsCentrally,或者反过来,项目文件里声明了Version。解决方式是确认根目录Directory.Packages.props里的属性已设置,并且项目文件里不要写Version。NU1608:检测到传递依赖的版本高于中央声明的版本。这个警告提示你可能有某个间接依赖没有受控,需要检查CentralPackageTransitivePinningEnabled是否开启,或者直接在PackageVersion里把对应包的版本锁到更高的版本。NU1605:版本降级警告。一般是包 A 依赖某个高版本的包,但解决方案里锁了一个低版本。我的处理习惯是:不轻易降级,先确认升级路径,如果升级会引入破坏性变化,就在中央文件里显式声明更高版本,而不是依赖 NuGet 自动降级。4.2 Artifacts 目录相关的坑ArtifactsPath的路径基准问题我前面提过一次,这里展开讲。如果你在Directory.Build.props里写的是:<ArtifactsPath>artifacts</ArtifactsPath>那么在 CI 里调用dotnet build时,不同项目的中间文件可能会被写到不同的“相对意义上的 artifacts”目录里,整个输出就会乱掉。正确做法始终是使用$(MSBuildThisFileDirectory),确保路径基于Directory.Build.props所在目录,也就是解决方案根目录。另一个问题是旧目录残留。如果你设置了UseArtifactsOutput,但项目目录下还有旧的obj文件,MSBuild 可能不会按预期把中间文件写到artifacts/obj,而是沿用旧目录。这会导致构建报MSB3021之类的文件复制错误。遇到这种情况,先全仓库搜索并删除所有bin和obj目录,再重新构建。这个操作建议写进团队文档里,因为新人加入时很容易踩上。4.3 容器发布阶段的坑容器发布虽然省事,但有几个问题经常遇到。第一个是基础镜像默认不包含证书信任配置,如果服务里要调用外部 HTTPS API,容器里可能出现证书校验失败。解决方式是在容器运行时挂载证书存储配置,或者用更完整的运行时镜像作为基础层。第二个是运行用户权限。默认容器发布生成的镜像经常配置为非 root 运行,这是安全上的好事,但如果你的服务有写文件或绑定特定端口的逻辑,可能会遇到权限不足。我的建议是把“非 root 运行”作为默认,然后根据日志报错逐项调整目录权限,不要轻易退回 root。第三个是架构参数。发布时用-r linux-x64只解决当前架构,如果团队里有 ARM64 的机器,需要在 CI 里分别构建两个架构的镜像并合并为 manifest 列表。这个和Artifacts没有直接关系,但既然在做发布标准化,架构矩阵迟早要处理,早规划比晚规划好。4.4 构建性能与参数调优集中输出到artifacts目录之后,多项目并行构建时所有项目共享同一个中间目录区域,文件 IO 的竞争会比以前分散式输出更明显。我实测过,在机械硬盘上的旧开发机上,这个变化能明显感知到;在固态硬盘上基本无感。如果你们团队有人遇到构建变慢,我建议先确认是不是每次都做了完整还原,而没有用--no-restore。实际上,在 CI 中还原和构建分离、本地开发时用增量构建,通常能抵消掉大部分 IO 开销。还有一个细节是团队尽量统一 SDK 版本,在根目录放一个global.json,避免不同开发机上的 SDK 行为不一致——尤其是涉及构建管线和输出路径时,SDK 版本差异容易被忽略但又非常折磨人。我在实际使用中还有个经验:Artifacts和 CPM 这组能力,最好在解决方案的“结构稳定期”引入,不要在业务功能大改版的时候顺手做。因为构建体系的改造必然引起一段时间的习惯调整,如果这时候业务需求也很密集,两边夹击很容易让人退缩回老方案。先把功能稳定了,再挑个迭代窗口把构建体系切过去,成功率会高很多。最后再分享一个小技巧:CPM 模式下,如果想把某个项目临时用不同版本的包做实验,不要直接改项目文件里的Version,那会和中央管理冲突。正确的做法是临时注释掉中央文件里对应的PackageVersion,或者用Update的方式在目标项目里显式覆盖——实验完了记得把状态恢复干净。这个小操作能避免很多“为什么我的版本没生效”的排查时间。
最近在帮团队把几个老的 .NET 解决方案往现代 SDK 风格迁移,越干越觉得,.NET 这些年构建和发布方式的变化,真的被很多人低估了。前两篇聊了聊整体趋势和发布模型,这篇作为系列第三篇,我打算把重点放在最实在的工程化改造上——构建产物怎么收拢、包版本怎么管得住、构建期怎么提前发现问题、发布镜像怎么少写脚本。全程不绕弯子,直接讲配置、讲思路、讲踩坑。这轮的“革新”跟以前不太一样。以前大家关注的是 C# 语法新特性、运行时性能,而构建发布体系一直处于“能跑就行”的状态。但从 .NET 开始把Artifacts输出布局、集中包管理、NuGet 安全审计、容器镜像发布这些能力直接内建进 SDK 之后,构建这件事终于从“脚本拼凑”走向了“工程化标准件”。这篇文章适合正在维护多项目解决方案的人,也适合那些被 CI 脚本和发布脚本折磨过的朋友——看完可以直接照着改。1. 内容整体设计与思路拆解1.1 从最让人崩溃的日常说起先说个我见得最多的场景:一个解决方案里几十个项目,每个项目都有自己的bin、obj目录,编译完之后整个仓库遍地都是构建产物。打包的时候要么让 CI 逐个项目遍历拷贝,要么写一个几百行的 MSBuild Target 去汇总,出问题的时候日志翻得人眼冒金星。版本管理更是一笔糊涂账——有的项目引用Newtonsoft.Json 12.0.3,有的引用13.0.1,升级版本靠全局搜索替换,替换完构建崩了还得一个个排查是谁不兼容。这套路我太熟悉了,因为我自己维护的老项目就是这么过来的。后来切换到一个新解决方案,才真正体会到 SDK 内置的构建治理能力有多省心。核心思路其实不复杂,就是“把约定变成默认”:不再靠每个人自觉遵守规范,而是让工具链强制统一输出路径、统一版本来源、统一安全检查、统一发布入口。说白了,就像给整个解决方案立了一套规矩,谁都不能例外。1.2 功能的组合拳,而不是单点补丁有人可能会问:Artifacts输出布局和集中包管理不是两个独立功能吗?为什么放在一起讲?我的理解是,这轮革新的价值不在于某个单一功能,而在于它们组合之后形成的整体体验。比如:Artifacts让所有项目的编译输出统一进artifacts/bin和artifacts/obj,CI 拿到干净的制品目录去归档;集中包管理让版本声明收敛到一个文件,和artifacts目录一起放进仓库根目录,整个解决方案的“状态”一目了然;NuGet 审计在构建期扫依赖漏洞,把安全问题从上线后前置到编译阶段;容器镜像发布则让dotnet publish直接输出镜像,省掉手工维护Dockerfile的重复劳动。四件事一环扣一环,本质上是在重新定义“一个 .NET 项目从源码到制品”的标准链路。我之前在某个模拟项目 X 上做改造时,把这几项一次性全落地,改造完之后最大的感受不是“功能变多了”,而是“脚本变少了”。删掉了几百行 PowerShell 和 MSBuild 自定义 Target,构建、测试、发布都回归到标准命令,团队成员上手成本也低了很多。1.3 这套方案适合什么场景说句实在话,并不是所有项目都适合马上全套改造。如果你是单项目、甚至只是一个类库,那Artifacts输出布局的意义不大,反而多了一层抽象;如果项目里用了大量自定义构建目标,并且这些目标对路径有硬编码,改到统一输出目录后可能有一批兼容性工作要做。最适合的场景是:多项目解决方案,并且 CI 有明确的制品归档需求;团队规模超过三个人,包版本管理靠口头约定已经出过问题;发布流程走容器化,而且不想每个服务维护一份Dockerfile。在这些场景下,新方案带来的收益远远大于改造成本。2. 核心细节解析与实操要点2.1 统一构建输出:Artifacts Output LayoutArtifacts输出的原理很简单:通过设置UseArtifactsOutput为true,SDK 会把所有项目的BaseOutputPath和BaseIntermediateOutputPath重定向到解决方案根目录下的artifacts文件夹。具体来说,编译产物统一进artifacts/bin/<项目名>/<配置>/<目标框架>/,中间文件统一进artifacts/obj/<项目名>/,不再留在各自的项目目录里。配置方式极其简单,在解决方案根目录放一个Directory.Build.props,写上:<Project> <PropertyGroup> <UseArtifactsOutput>true</UseArtifactsOutput> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> </PropertyGroup> </Project>我特别强调ArtifactsPath要基于MSBuildThisFileDirectory,而不是写死一个相对路径。因为 MSBuild 中相对路径的基准目录会随执行上下文变化,用MSBuildThisFileDirectory才能保证无论从哪里调用构建,输出的落点都是同一个地方。这是我踩过的坑之一,后面会细说。这套机制对 CI 的收益最大。以前 CI 要遍历每个项目目录把bin/Release找出来,现在一行通配符就能拿到全部产物,比如artifacts/bin/**/publish/*.zip。清理也简单,删除整个artifacts目录即可,不会留下零散的obj目录把仓库弄脏。注意一点:这个功能只对使用 SDK 风格的项目有效。如果解决方案里还残留着旧的非 SDK 风格项目格式,需要先迁移,否则设置会被忽略。迁移本身工作量不大,但确实得做。2.2 集中包管理:Central Package Management集中包管理(我后面简称 CPM)解决的痛点非常明确:包版本散落在各个项目文件里,升级成本高,一致性难保证。它的做法是把“版本”和“引用”拆开——在根目录的Directory.Packages.props里集中声明每个包的版本,项目文件里只写引用了哪个包,不写版本号。具体配置是这样,先在根目录建Directory.Packages.props:<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> </PropertyGroup> <ItemGroup> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageVersion Include="FluentValidation" Version="11.9.0" /> </ItemGroup> </Project>然后在项目文件里这样引用:<ItemGroup> <PackageReference Include="Newtonsoft.Json" /> <PackageReference Include="Microsoft.Extensions.Hosting" /> </ItemGroup>项目文件如果没有显式写Version,构建时就会自动从Directory.Packages.props里的PackageVersion取值。这样做的最大好处是,整个解决方案的依赖版本只有一个出处,想升级某个包,改一处即可。CentralPackageTransitivePinningEnabled是我强烈建议开启的属性。它的作用是让间接依赖(传递依赖)的版本也受中央版本文件约束,避免出现“我明明把包 A 锁到了 1.0,结果包 B 依赖的包 A 还是 1.1 传递上来了”这种失控情况。尤其是在大型解决方案里,没有这个 pinning,间接依赖漂移的事故概率非常高。2.3 构建期安全审计:NuGet AuditNuGet 审计是我觉得这轮革新里“最不起眼但最实用”的功能。以前查依赖漏洞,要么靠上线前的安全扫描,要么靠人工关注公告,等发现漏洞的时候,项目通常已经带着漏洞跑了好几个月了。NuGet Audit 把这件事直接拉到了构建期。它不需要额外配置,SDK 默认会在dotnet restore和dotnet build时检查和项目相关的包有没有已知漏洞,如果有,会输出类似NU1900系列的错误或警告。默认情况下是警告,但我建议团队内部把它提升为错误,强制阻断带漏洞的依赖进入主分支:<PropertyGroup> <NuGetAuditMode>all</NuGetAuditMode> <WarningsAsErrors>$(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsAsErrors> </PropertyGroup>NuGetAuditMode有两个值,direct只检查直接引用的包,all连传递依赖一起检查。我一般用all,因为间接依赖里的漏洞往往更隐蔽。不过也要有心理准备,设为all之后第一次构建可能冒出一堆警告,因为很多老项目里传递依赖的版本确实过时了。这时候不用慌,顺着警告的线索升级依赖即可,这是个渐进的过程,不是一次性大换血。3. 实操过程与核心环节实现3.1 改造前的项目面貌我拿一个典型的模拟项目 X 举例,它长这样:/解决方案根目录 /src /App.Api (Web API 项目) /App.Core (领域逻辑类库) /App.Infrastructure (数据访问类库) /tests /App.Api.Tests (接口测试项目) /scripts build.ps1 (自定义构建脚本) package.ps1 (打包脚本) /deploy Dockerfile (手工维护的镜像文件)改造前的问题很明显:每个项目都有自己的bin和obj,build.ps1里有一段二三十行的逻辑专门去各个目录收集 DLL;Dockerfile里COPY的路径依赖项目名和配置名,换一个项目就要改一遍;App.Infrastructure引用的Newtonsoft.Json版本和App.Api不一致,接口返回的 JSON 序列化行为还不一样。改造的思路就是一步步把这些手工活交给 SDK 的默认机制。3.2 构建侧三步改造第一步,在根目录创建Directory.Build.props,开启Artifacts输出。这一个文件的改动,整个解决方案的构建输出立即统一到artifacts目录下,所有项目共用一套目录规范。第二步,创建Directory.Packages.props,把各项目文件里散落的Version属性全部收拢。这里有个操作细节:先别急着一口气改完所有项目,最好先把根文件建好,然后逐个项目把PackageReference的Version去掉,每去掉一批就dotnet build一次。为什么?因为有的包可能只被某个项目引用,版本声明不在你的记忆范围内,靠编译报错来找漏网之鱼反而更精确。第三步,在 CI 或本地验证:dotnet restore ./Xxx.sln dotnet build ./Xxx.sln -c Release --no-restore构建结束后,产物会整齐落在artifacts/bin下,不会在项目目录产生新的bin和obj。我建议顺手把原来项目里的bin、obj目录全部删除,确保新旧两套机制不混合。如果不删,MSBuild 可能会优先读取旧目录里的中间文件,造成各种诡异的构建缓存问题。3.3 发布侧:容器镜像一键发布老方案里的Dockerfile长什么样,用过的朋友都知道。要处理基础镜像、工作目录、COPY顺序、dotnet restore和dotnet publish分段,还要小心 NuGet 源的还原缓存。每个服务都要写一份类似的,改起来就是个体力活。新方案是让 SDK 直接支持容器镜像发布。你只需要在项目文件里声明镜像信息:<PropertyGroup> <ContainerImageName>my-service</ContainerImageName> <ContainerImageTag>1.0.0</ContainerImageTag> <PublishProfile>DefaultContainer</PublishProfile> </PropertyGroup>然后执行:dotnet publish ./src/App.Api/App.Api.csproj -c Release -r linux-x64 --self-contained false -p:PublishProfile=DefaultContainerSDK 会自动完成镜像构建和推送,你不需要写 Docketfile,也不需要本地安装容器命令行工具。对于只需要“发布出一个能跑的镜像”的大多数 Web 服务来说,这套方式干净利落,少维护一份文件就少一份出错的可能。有人会担心灵活性不够。我的看法是:如果你的镜像需求复杂到需要多层自定义脚本、特殊基础镜像、复杂的健康检查参数,那你可以继续保留Dockerfile写自定义逻辑;但绝大多数内部服务根本用不到那些功能,用默认容器发布反而更省事。我的习惯是“默认用内置,特殊再绕行”。3.4 把整条链路串进 CI构建和发布都标准化之后,CI 脚本会变得非常简单。以常见的发布流程为例,大致是:# 还原、构建、跑测试 dotnet restore ./Xxx.sln dotnet build ./Xxx.sln -c Release --no-restore dotnet test ./Xxx.sln -c Release --no-build --logger "trx;LogFileName=results.trx" # 发布 Web 项目,直接输出容器镜像并推送到镜像仓库 dotnet publish ./src/App.Api/App.Api.csproj -c Release -r linux-x64 \ -p:PublishProfile=DefaultContainer \ -p:ContainerRegistry=registry.example.com \ -p:ContainerRepository=team-a/app-api注意,--no-build这个参数很重要。它确保测试和发布都复用同一份编译产物,不会出现“构建一次,发布又编译一次”的时间浪费。配合Artifacts统一输出,整个流水线的中间产物路径是确定的,脚本里再也不需要写“遍历目录找 DLL”这种东西了。4. 常见问题与排查技巧实录4.1 CPM 的报错速查集中包管理接入后,最常见的几个报错我列一下,都是实际会遇到的:NU1004:项目里写了PackageVersion但没开ManagePackageVersionsCentrally,或者反过来,项目文件里声明了Version。解决方式是确认根目录Directory.Packages.props里的属性已设置,并且项目文件里不要写Version。NU1608:检测到传递依赖的版本高于中央声明的版本。这个警告提示你可能有某个间接依赖没有受控,需要检查CentralPackageTransitivePinningEnabled是否开启,或者直接在PackageVersion里把对应包的版本锁到更高的版本。NU1605:版本降级警告。一般是包 A 依赖某个高版本的包,但解决方案里锁了一个低版本。我的处理习惯是:不轻易降级,先确认升级路径,如果升级会引入破坏性变化,就在中央文件里显式声明更高版本,而不是依赖 NuGet 自动降级。4.2 Artifacts 目录相关的坑ArtifactsPath的路径基准问题我前面提过一次,这里展开讲。如果你在Directory.Build.props里写的是:<ArtifactsPath>artifacts</ArtifactsPath>那么在 CI 里调用dotnet build时,不同项目的中间文件可能会被写到不同的“相对意义上的 artifacts”目录里,整个输出就会乱掉。正确做法始终是使用$(MSBuildThisFileDirectory),确保路径基于Directory.Build.props所在目录,也就是解决方案根目录。另一个问题是旧目录残留。如果你设置了UseArtifactsOutput,但项目目录下还有旧的obj文件,MSBuild 可能不会按预期把中间文件写到artifacts/obj,而是沿用旧目录。这会导致构建报MSB3021之类的文件复制错误。遇到这种情况,先全仓库搜索并删除所有bin和obj目录,再重新构建。这个操作建议写进团队文档里,因为新人加入时很容易踩上。4.3 容器发布阶段的坑容器发布虽然省事,但有几个问题经常遇到。第一个是基础镜像默认不包含证书信任配置,如果服务里要调用外部 HTTPS API,容器里可能出现证书校验失败。解决方式是在容器运行时挂载证书存储配置,或者用更完整的运行时镜像作为基础层。第二个是运行用户权限。默认容器发布生成的镜像经常配置为非 root 运行,这是安全上的好事,但如果你的服务有写文件或绑定特定端口的逻辑,可能会遇到权限不足。我的建议是把“非 root 运行”作为默认,然后根据日志报错逐项调整目录权限,不要轻易退回 root。第三个是架构参数。发布时用-r linux-x64只解决当前架构,如果团队里有 ARM64 的机器,需要在 CI 里分别构建两个架构的镜像并合并为 manifest 列表。这个和Artifacts没有直接关系,但既然在做发布标准化,架构矩阵迟早要处理,早规划比晚规划好。4.4 构建性能与参数调优集中输出到artifacts目录之后,多项目并行构建时所有项目共享同一个中间目录区域,文件 IO 的竞争会比以前分散式输出更明显。我实测过,在机械硬盘上的旧开发机上,这个变化能明显感知到;在固态硬盘上基本无感。如果你们团队有人遇到构建变慢,我建议先确认是不是每次都做了完整还原,而没有用--no-restore。实际上,在 CI 中还原和构建分离、本地开发时用增量构建,通常能抵消掉大部分 IO 开销。还有一个细节是团队尽量统一 SDK 版本,在根目录放一个global.json,避免不同开发机上的 SDK 行为不一致——尤其是涉及构建管线和输出路径时,SDK 版本差异容易被忽略但又非常折磨人。我在实际使用中还有个经验:Artifacts和 CPM 这组能力,最好在解决方案的“结构稳定期”引入,不要在业务功能大改版的时候顺手做。因为构建体系的改造必然引起一段时间的习惯调整,如果这时候业务需求也很密集,两边夹击很容易让人退缩回老方案。先把功能稳定了,再挑个迭代窗口把构建体系切过去,成功率会高很多。最后再分享一个小技巧:CPM 模式下,如果想把某个项目临时用不同版本的包做实验,不要直接改项目文件里的Version,那会和中央管理冲突。正确的做法是临时注释掉中央文件里对应的PackageVersion,或者用Update的方式在目标项目里显式覆盖——实验完了记得把状态恢复干净。这个小操作能避免很多“为什么我的版本没生效”的排查时间。