
这阵子陆续在整理 C# 8.0 和 .NET 5 的学习笔记前面几篇把语言层面的新语法、异步模式、集合与 LINQ 都过了一遍。这一篇我想聊一个特别容易被忽略、却又真正决定代码能不能跑起来的话题类型本身是怎么回事以及 .NET 是以什么单位把类型打包、分发、部署出去的。也就是说我们每天写的 class、struct、interface 到底被装进了什么容器里这个容器又如何参与版本管理、依赖解析和最终发布。如果你刚开始学 .NET或者写了好几年代码却很少打开项目文件之外的东西这篇笔记应该能帮你补上那块看起来很基础、实际上很重要的拼图。1. 先想清楚一个问题我们写的 class 到底存在哪1.1 类型在运行时的真实载体很多人刚开始学 C# 的时候会下意识地把“类”理解成源码里的一个代码片段一个文件里写一个大括号里面放属性、方法、事件感觉这就是类型的全部。但从编译器角度来说源码只是起点。C# 代码经过编译器之后并不会变成机器码存放而是被转换成一种中间语言ILIntermediate Language同时生成一份描述类型结构的元数据Metadata最后这两样东西会被一起封装进一个程序集Assembly。程序集就是我们常说的.dll或者.exe文件。可以这么理解类型是内容程序集是容器。你写了一个Person类编译之后它不会散落在一片文件里而是整体被装进MyApp.dll这样的程序集中。运行时加载程序集读取里面的元数据然后再把 IL 交给即时编译器JIT执行。所以“某个类型到底能不能被找到”这个问题本质上不是“你有没有写这个类”而是“这个类编译进了哪个程序集程序集有没有被正确引用和加载”。我做这类排查时第一反应永远是打开项目引用列表看一遍而不是盯着源码找类名。因为编译错误提示“找不到类型”绝大多数情况不是代码写错而是根本没引用对应的程序集。1.2 CTS、BCL 和程序集的分工要真正理解类型还得把 .NET 平台层面的一套基础规则和类型打包区分开。平台提供了一套通用类型系统CTSCommon Type System它规定了一个“类型”应该长成什么样值类型和引用类型有什么区别接口继承如何表达方法签名包括哪些部分。C# 里的int、long、bool到了 IL 层面就变成了int32、int64、bool这些统一表示靠的就是 CTS。它像一个类型宪法让不同语言编译出来的代码可以在同一个运行时里互相调用。平台还提供了一大批写好的基础类型比如System.String、System.Collections.Generic.ListT、System.IO.File这些。它们不是由 CTS 凭空定义的而是由一组基础类库程序集提供的。也就是说就算你不写任何类你的程序也必然依赖一堆平台程序集比如System.Runtime.dll、System.Console.dll。而你自己写的类型与你引用的这些基础类型一起在打包和部署时形成了一个依赖网络。我见过不少新手同学把这三层混成一谈于是出现“为什么我明明写了using System;还是找不到Console”的困惑。其实using解决的是命名空间的导入问题而Console类型本身住在某个基础程序集里那个程序集必须被正确引用。命名空间帮编译器找到类型名字程序集告诉运行时去哪里加载这个类型二者缺一不可。1.3 值类型、引用类型和泛型在打包时意味着什么从打包视角看值类型和引用类型的差别不仅仅是栈和堆的分配问题它还影响程序集之间的关系。一个struct会被内联到使用它的类型里一个class则通过对象引用访问。但这都是运行时层面的故事。编译期值类型、引用类型、接口、委托、枚举这些类型定义都会被写进同一个程序集的元数据表里。值得多说的是泛型。C# 里的ListT是一个泛型类型定义在程序集里保存的是“未绑定”的类型定义而不是给string、int分别生成一份完整类型。真正特化发生在运行时JIT 会根据实际类型参数生成对应的可执行代码。这就意味着泛型类型打包起来很轻但反射时看到的往往是一个开放的泛型定义而不是具体的Listint。后续如果我们做程序集扫描或者生成代码碰到泛型就必须小心处理。2. 命名空间给类型起的“门牌号”但不是物理文件2.1 namespace 和 assembly 是两回事命名空间和程序集是 .NET 里最容易混淆的两个概念。简单说命名空间是逻辑上的组织方式程序集是物理上的打包单位。一个程序集里可以包含多个命名空间比如Demo.Shared项目里同时存在Demo.Shared.Models和Demo.Shared.Services这两个命名空间。反过来一个命名空间也可以横跨多个程序集比如System.Collections.Generic这个命名空间里的类型分散在好几个不同程序集中。为什么搞清楚这一点很重要因为你在代码里写using Demo.Shared.Models;编译器会把这个命名空间中的所有类型都纳入候选范围但如果这些类型所在的程序集没有被引用编译器依然会说找不到类型。换言之using是去掉名字前缀引用程序集才是提供类型本体。在实际开发中我见过有人把“新建文件夹”当作“新建命名空间”觉得文件放在物理目录里相同层级就能自动属于同一命名空间。这在 SDK 风格的现代项目里偶尔能碰巧对上因为默认的根命名空间是由项目名决定的但一旦你显式修改了文件夹名或者命名空间名二者就会错开。一旦错开别人看代码时就会发现类型归属混乱排查引用关系的时候非常难受。规范做法是命名空间只作为逻辑分组的工具不要依赖物理目录去推断它。2.2 命名空间设计里的两个常见坑第一个坑是根命名空间起得太随意。如果一个类库项目直接叫Utils那么它的命名空间大概率也是Utils。等这个包被三五个项目引用之后Utils这种过于通用的名字会和别的包的命名空间撞车代码里到处出现Utils.Utils.Extension这种滑稽的调用。我建议命名空间至少带上公司或团队代号、产品名、模块名比如CompanyName.ProductName.Module这样既能避免碰撞也方便后续做代码扫描。第二个坑是命名空间和程序集名称不一致。项目文件里的RootNamespace和输出的程序集文件名.dll文件名往往由项目文件名决定二者如果相差太大别人拿到你的一堆 dll 时很难快速判断“哪个包提供哪个命名空间”。尤其在做 NuGet 包分发时包名、程序集名、根命名空间三者应该保持清晰稳定的对应关系否则用户引入一个包之后还要靠反射才能搞清楚里面有什么类型体验会非常差。3. 程序集.NET 类型的打包单位3.1 为什么必须有程序集这一层如果把一个类型比喻成一本书的内容那程序集就是装订好的书册。我们可以单独编辑某一页但要借给别人、搬到书架上、给图书编号必须是一本一本的实体。没有程序集这个概念整个应用就是一锅粥编译器没办法确定类型之间的依赖边界运行时也没办法做版本隔离。程序集在 .NET 里承担了好几件事。它是代码加载的最小单元CLR 不会单独加载某个类型而是先加载包含该类型的程序集。它也是版本绑定的基本单元你引用的通常是整个程序集的版本而不是单独某个类的版本。它还是安全边界和部署单元一个应用程序由若干程序集组成每个程序集都可以有独立的构建和签名设置。从工程管理的角度看程序集决定了你的代码边界。如果你把几百个类型塞进一个程序集那这个程序集就是一个巨大的耦合体任何改动都可能影响所有使用方。反过来如果你把每个小功能都拆成一个程序集加载开销和依赖管理又会变成噩梦。所以我通常的建议是按功能模块拆程序集而不是按类拆一个程序集就是一组高内聚、低耦合的类型集合。3.2 私有程序集与共享程序集的今昔变化在早期的 .NET Framework 时代程序集分为私有程序集和共享程序集两种。私有程序集直接放在应用程序目录下只服务这一个应用共享程序集则要安装到全局程序集缓存GAC里所有应用共用同一份。GAC 的好处是物理上只有一份坏处是部署和版本管理都非常僵硬业界把这种冲突叫“DLL 地狱”。到了 .NET Core 和 .NET 5 时代平台把 GAC 彻底移除共享逻辑变成了“包管理”。类型通过 NuGet 包分发包被下载到每台机器的一个本地缓存目录然后按项目构建输出复制到各自的应用目录。这看起来是“退步”实际是彻底解决了全局共享带来的版本污染问题。现在你每个应用都带着自己依赖的程序集副本互不干扰第三方包可以同时存在多个大版本。我在实际项目里见过不少人顺着 Framework 的老思路试图把自己写的一个公共 dll 手动丢到系统目录里让所有服务共用。这个做法在新平台下已经不可取正确方式是发布一个 NuGet 包或者至少通过项目引用和内部源来管理公共代码。3.3 元数据程序集的“自述文件”程序集不是一个只有代码的黑盒它还包含一套非常详细的“自述文件”也就是元数据。元数据以二进制表的形式存储在程序集文件里记录了程序集中的每一个类型类型的名字、可见性、基类、实现的接口、每个方法的签名、字段和属性的具体描述以及程序集本身引用了哪些外部程序集的哪些版本。这就是为什么 .NET 程序集被称为“自描述”的原因。我们平时说的“反射”本质上就是读取这些元数据表。比如Assembly.GetTypes()遍历一个程序集中的所有类型就是从这个程序集的元数据里拿到类型列表。IL 是逻辑元数据是目录和说明二者一起才构成一个完整的程序集。正因如此即使没有额外的头文件或接口描述文件.NET 也能在运行时精确判断一个程序集是否满足另一个程序集的调用要求。打包 .NET 类型时元数据不会被刻意隐藏它能被任何代码读取。有些开发者误以为 “internal 类型放进 dll 后别人看不见”其实用反射工具一照类型名和方法名都能被看见。internal 只是编译器和语言层面的访问限制不是加密保护。如果你的代码里有真正不能暴露的保密逻辑别指望打包能拦住什么该做混淆的做混淆该放服务端的放服务端。3.4 程序集信息与版本号每个程序集都有一个版本号体系它由三个概念组成很多人把它们混在一起。第一个是AssemblyVersion这是运行时用来做程序集绑定的版本改动它会影响强名称匹配。第二个是FileVersion它主要给操作系统文件属性面板看的不影响运行时加载。第三个是InformationalVersion通常用来展示产品版本比如“1.0.0-beta.1”。在 SDK 风格的项目文件里一个Version1.2.3/Version属性会同时影响多个版本号所以很容易出现“我只想改产品版本结果连运行时绑定版本也被改了”的意外。为了避免这种隐式影响我会在正式类库里显式地区分它们PropertyGroup Version1.2.3/Version AssemblyVersion1.0.0.0/AssemblyVersion FileVersion1.2.3.0/FileVersion InformationalVersion1.2.3-beta.1/InformationalVersion /PropertyGroup版本号是程序集身份的一部分。实际排错时我发现很多人改了代码不升版本然后dotnet pack也默认生成了同名同版本的新 nupkg结果在本地源里新旧混在一起根本分不清哪个是新的。这看起来是小事却会浪费一个下午。我的习惯是只要产出物对外的行为变了就同步提高版本号哪怕只是加一个修订号。4. 类型的可访问性打包之前先想清楚哪些类型能见人4.1 public 与 internal 的选择打包类型时最关键的设计决策并不是“这个类放哪个命名空间”而是“这个类的可见性设成什么”。一个类一旦被标记为public它就会出现在程序集的公共 API 表面上所有引用这个程序集的外部代码都可能直接使用它。这意味着那个类的方法、属性、构造函数都成了你对外做出的长期承诺。以后改方法签名、删掉某个属性、调整构造逻辑都可能破坏所有使用者的代码。很多经验不足的开发者写类库时喜欢把每个类都设为public理由是“以后别人可能会用到”。这其实是一个典型的过度设计。真正诚实的做法是默认使用internal class只有当你明确知道某个类型需要被外部消费时才显式改成public。internal类型只对本程序集可见编译器和 IDE 会拦住外部代码对它的访问相当于把实现细节关在门内。我在项目里常用的策略是接口定义对外公开实现类全部internal。比如对外只暴露IUserRepository实际实现的UserRepository是internal class。这样外部调用者拿到的是抽象我以后想替换实现类的时候只需要改内部绑定逻辑不会破坏任何使用者。这种“公共接口加私有实现”的模式在依赖注入项目里非常好用。4.2 友元程序集 InternalsVisibleTo当你把实现类做成了internal又会遇到一个现实问题单元测试项目怎么访问这些内部类型如果完全不能访问很多内部逻辑只能靠公共接口间接测试覆盖率和可测性都会下降。为此平台提供了“友元程序集”机制你可以通过InternalsVisibleTo特性把自己的内部类型暴露给某一个指定程序集。在传统写法里我们需要在AssemblyInfo.cs里写[assembly: InternalsVisibleTo(Demo.Shared.Tests)]在 SDK 风格的现代项目里更推荐直接在项目文件里声明ItemGroup InternalsVisibleTo IncludeDemo.Shared.Tests / /ItemGroup这么做有几个好处声明集中在项目文件里不会混杂到代码中多个测试程序集可以并列声明构建系统可以自动处理签名相关的问题。需要注意的是InternalsVisibleTo是“单向授权”被授权的程序集能看你的内部类型但你的程序集看不到它的内部类型。它是测试和拆解巨型类的重要工具箱但不要滥用否则整个“隐藏实现细节”的设计就形同虚设。尤其是如果程序集做了强命名友元程序集必须也有强命名并且要附带公钥信息否则运行时会直接拒绝加载。4.3 public 类型搭上不公开的构造方式有时候你需要把一个类型暴露给外部但又不希望外部随意创建实例。比如工厂方法返回一个OrderEntity调用方只能拿着这个实体读数据不能随便new一个塞进数据库。这种场景下类型本身是public但构造函数可以是internal甚至private。外部代码能看到这个类型也能使用它公开的方法却无法绕过工厂直接创建实例。public class OrderEntity { internal OrderEntity() { } public string OrderNo { get; init; } public decimal Amount { get; init; } }这样做等于在“类型的可见性”和“构造方式的可访问性”之间切了一刀。打包后的效果是外部程序集能看到这个类型会把它列进公共 API但创建一个真实可用的实例必须通过你的工厂方法或映射逻辑。类似的思路还有公开类型内部用了大量internal成员外部根本无法感知公开的属性用internal set防止外部修改关键状态。这些都是“打包”时常见的组合技巧值得在类库设计阶段就想清楚。5. 实操从类库到 NuGet 包的完整打包流程5.1 创建类库项目说了这么多概念我们来走一条实际的流程把一堆类型变成一个可以分发的 NuGet 包。假设我要做一个Demo.Shared类库第一步自然是创建一个类库项目dotnet new classlib -n Demo.Shared -f net5.0-f net5.0指定目标框架。这里插一句选型建议如果你的类库需要被多种框架引用比如同时被 .NET 5、.NET 6、甚至某些特殊运行时使用那么目标框架可以选netstandard2.0它可以兼容更广的平台。但如果你希望用上较新的运行时特性比如init、record、或者某些新 API那就直接以net5.0或更高版本为目标。在实际团队协作中我一般让底层公共库尽量往netstandard2.0靠而应用层直接使用最新的目标框架。创建完成后项目里会有一个默认的Class1.cs我会删掉它然后按模块建立规范的目录。目录结构这时候开始和命名空间交互所以我在项目文件里会显式设置RootNamespace避免臆测PropertyGroup TargetFrameworknet5.0/TargetFramework RootNamespaceDemo.Shared/RootNamespace Nullableenable/Nullable /PropertyGroup5.2 配置项目描述信息要把类库变成 NuGet 包光有代码和输出 dll 是不够的。NuGet 包本质上还带了一份.nuspec描述清单里面包含包名、版本、作者、描述、依赖项等。SDK 风格的项目不需要手写.nuspec直接在项目文件里写对应属性就行。一个比较完整的类库项目文件长这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet5.0/TargetFramework RootNamespaceDemo.Shared/RootNamespace Nullableenable/Nullable PackageIdDemo.Shared/PackageId Version1.0.0/Version AuthorsMyTeam/Authors Description团队内部公共类型库包含订单模型和通用工具。/Description PackageTagsshared, common, utility/PackageTags PackageLicenseExpressionMIT/PackageLicenseExpression RepositoryUrlhttps://example.invalid/Demo.Shared/RepositoryUrl GenerateDocumentationFiletrue/GenerateDocumentationFile IncludeSymbolstrue/IncludeSymbols SymbolPackageFormatsnupkg/SymbolPackageFormat /PropertyGroup /ProjectGenerateDocumentationFile会生成 XML 注释文件这对使用方特别重要。因为 NuGet 包被消费时IDE 的智能提示会读取这个 XML 文件没有它使用者看不到你类型的注释。很多开发者忽略这一点打包出去后别人用起来像盲人摸象。IncludeSymbols和SymbolPackageFormat则是生成调试符号包发布到内部源之后开发者在调试时能进入你的库源码而不是只看到一堆“外部代码”。5.3 打包与观察产物结构配置写好后执行打包命令dotnet pack -c Release -o ./artifacts-c Release是因为调试版本往往没有做优化且会包含大量调试信息不适合分发。-o ./artifacts把产物集中到一个目录。执行完你会在artifacts里看到Demo.Shared.1.0.0.nupkg和可选的Demo.Shared.1.0.0.snupkg。.nupkg文件本质是一个 zip你随手用解压工具打开就能看到它的内部结构lib/net5.0/Demo.Shared.dll程序集本体这是类型的最终载体Demo.Shared.nuspec包描述清单包含依赖关系[Content_Types].xml、_rels/.rels、package/servicesNuGet 包的容器描述文件这里要特别注意lib下的目录名。lib/net5.0表示这个程序集只服务于net5.0及其后续兼容框架。如果你的包需要支持多个目标框架项目文件里可以写TargetFrameworksnetstandard2.0;net5.0/TargetFrameworks打包后lib下就会同时出现lib/netstandard2.0和lib/net5.0两个目录。NuGet 在恢复时会根据使用方项目的目标框架自动选择最合适的一份。这正好体现了“打包类型”的核心同一个类型可以有不同平台上的不同实现由包管理器按规则做选择。5.4 在另一个项目消费本地包打好的包最终要被其他项目用起来。开发阶段最简单的验证方式是把artifacts目录注册成一个本地包源dotnet nuget add source ./artifacts -n local然后新建一个控制台项目添加包引用dotnet add package Demo.Shared --version 1.0.0或者在项目文件里直接写ItemGroup PackageReference IncludeDemo.Shared Version1.0.0 / /ItemGroup这里有一个很多人踩过的坑如果你在同一个解决方案里同时维护类库和消费方项目直接用项目引用ProjectReference通常比包引用更方便调试因为改代码不用重新打包。但项目引用不会建立“版本边界”你的类库一旦改名或拆分所有引用方都会立刻受影响。正式发布和对外分发时一定要切回 PackageReference用严格版本号锁定上游行为。5.5 打包前必看的几个检查项打包不是跑一条命令就完事交付前我会按以下清单检查一遍程序集版本是否和包版本一致如果AssemblyVersion没有显式指定它默认会使用Version导致运行时绑定版本随包版本频繁变化。要让版本稳定就显式区分。是否包含 XML 注释文件没有注释的包基本等同于“不可用”尤其对公共类型来说。是否有不该公开的类型跑进了公共 API 面用dotnet api这类工具扫描一遍对外类型确认没有误标public。依赖项是否都列全了打开生成的.nuspec文件看依赖清单是否符合预期。是否做了多次dotnet pack但版本没变本地源里的旧包会被缓存真正放行前最好清理一次缓存。我在这个环节吃过亏。有一次我把一个公共基础库打成了 1.0.0后面修改了一个小 bug又打了个 1.0.0结果本地源里的包因为缓存还是旧的。排查半天发现是缓存问题后来就养成了“打包前看一眼版本号”的习惯并且每次发布前都执行一次本地缓存清理。6. .NET 5 下的其他打包方式自包含、单文件与裁剪6.1 框架依赖发布和自包含发布的差别类型打包到程序集程序集打包到应用应用最终要部署到目标机器。传统框架依赖发布Framework-Dependent只输出你的代码和目标运行时之外的依赖项目标机器上必须安装了对应版本的 .NET 运行时才能启动。而自包含发布Self-Contained会把整个运行时复制进发布目录目标机器不需要预装 .NET。两者的发布命令差异不大# 框架依赖 dotnet publish -c Release -f net5.0 # 自包含指定某个运行时标识RID dotnet publish -c Release -f net5.0 -r linux-x64 --self-contained true-r linux-x64是运行时标识告诉构建系统“我要部署到 Linux x64 环境”它会相应的为这个平台生成原生的可执行文件。自包含带来的好处是部署机器零依赖缺点是发布体积大占用磁盘多更新时全部文件都要替换。从实际运维角度看如果目标环境可控我优先选框架依赖如果目标环境五花八门、不方便装运行时比如一些边缘设备那就用自包含。6.2 单文件发布把一堆类型合成一个可执行文件在 .NET 5 里你还可以把应用的所有托管 dll 打包进一个可执行文件这就是单文件发布。命令很简单dotnet publish -c Release -r win-x64 -p:PublishSingleFiletrue --self-contained false也可以写在项目文件里让发布行为固化PropertyGroup PublishSingleFiletrue/PublishSingleFile SelfContainedtrue/SelfContained RuntimeIdentifierwin-x64/RuntimeIdentifier IncludeNativeLibrariesForSelfExtracttrue/IncludeNativeLibrariesForSelfExtract /PropertyGroup单文件发布后用户拿到的是一个单独的MyTool.exe不用解压、不用拷贝一堆 dll非常适合做命令行小工具和内部传播的小程序。但要注意两点第一如果代码里用了非托管的原生库比如某些图像处理原生库单文件默认打包后会在运行时把这些原生库解压到临时目录必要时需要开启IncludeNativeLibrariesForSelfExtract让原生库也打包进执行文件。第二ASP.NET Core 的大型服务不建议强上单文件否则启动时的解压和程序集加载压力可能会带来冷启动变慢的问题。6.3 程序集裁剪让类型被“用不上”的抛弃与单文件发布经常配合出现的是程序集裁剪。启用后构建系统会分析你实际引用的类型和成员把那些没被用到的 IL 和元数据从最终产物里移除dotnet publish -c Release -r win-x64 -p:PublishTrimmedtrue裁剪能显著缩小发布体积但它引入了很多风险。最典型的场景是反射你代码里用Assembly.GetTypes()扫描某个程序集里的所有类型裁剪器无法静态确认你到底需要哪些类型可能把目标类型当作“未使用”一并砍掉运行时才抛MissingMethodException。如果代码里有大量基于反射的动态逻辑我建议要么不裁剪要么明确使用裁剪保留配置比如在项目文件里声明TrimmerRootAssembly或者在类上加[DynamicallyAccessedMembers]相关注解。对绝大多数面向普通用户的分发场景我给出的实际建议是裁剪只用在对外体积敏感且代码可静态分析的项目里。内部服务部署磁盘空间通常不是瓶颈不裁剪反而更稳。7. 常见问题与排查技巧7.1 编译时报“找不到类型或命名空间”这是新手最常遇到的错误报错通常形如CS0246: 未能找到类型或命名空间名“Person”。我排查这个问题从来不看源码而是分三层走。第一层这个类型真的存在吗打开类库源码确认类名和命名空间写对了。第二层当前项目引用包含那个程序集吗在 Visual Studio 或dotnet list package里检查包引用。如果用了项目引用确认引用目标确实在构建。第三层目标框架是否兼容一个net5.0项目引用一个net5.0类库没问题但反过来就做不到。类库的目标框架只能等于或低于使用方的目标框架。还有一个非常隐蔽的问题你以为引用了包但包里根本没有和命名空间对应的程序集。最后确认一下obj/project.assets.json文件里面记录了当前项目所有依赖包及其程序集清单。如果包被正常恢复assets里应该能看到对应条目看不到就说明 restore 出了问题。7.2 运行时出现“未能加载文件或程序集”编译能过启动却抛FileNotFoundException或FileLoadException这多半是程序集版本和加载路径出问题了。现代 .NET 里最常见的原因是两个 NuGet 包传递依赖了同一个基础程序集的不同版本最终产物中摆在应用目录下的版本不能满足其中一个的要求。在 .NET Framework 时代这类问题通常用绑定重定向解决。到了 .NET Core/.NET 5绑定重定向已经基本退出历史舞台正确做法是让所有依赖“对齐”到一个统一版本。具体操作上我会在最上层的应用项目里显式添加一个高版本的基础包引用比如直接引用System.Text.Json的某个新版本强制依赖图统一。然后用dotnet list package --include-transitive查看完整的传递依赖树确认没有哪个包还在要求旧版本。7.3 NuGet 本地源里的包一直是旧版频繁打包后很容易陷入“明明重新打了包别处引用还是旧代码”的怪圈。原因是 NuGet 在全局缓存里存放了包内容相同 ID 相同 Version 会命中缓存根本不会重新下载。即使你在本地源目录看到了新 nupkg 文件全局缓存里躺着的依然是旧内容。解决办法有两个。推荐的是改版本号开发阶段每次打包都带不同的预发布版本号比如1.0.0-alpha.1保证缓存不命中旧包。另一个是清理缓存dotnet nuget locals all --clear这会把全局缓存、临时缓存、插件缓存全部清空。注意这台机器上其他项目如果也引用了这些包清理后首次 restore 会变慢。所以我只在必要的时候清清缓存日常更多是确保版本号递增。这个习惯坚持下来基本不会再被“旧包”坑到。7.4 反射拿不到 internal 类型不是权限问题有时候你会用反射去读取一个程序集里的所有类型发现GetTypes()返回的列表里没有某些internal类型。这不是权限受限也不是类型不存在。internal只是阻止源码直接引用反射 API 默认照样会返回所有公开和非公开类型。真正看不到的原因多半是类型被编译器作为“私有实现细节”优化了或者该程序集的重定向导致你加载的是另一份副本。如果确实需要读取内部类型可以用重载的Assembly.GetType(name, true)如果名字写错会抛异常而不是返回 null更容易定位问题。要创建内部类型的实例可以使用Activator.CreateInstance(type, nonPublic: true)。但这里我要强调一点如果你是在做框架层面的动态加载依赖internal类型的名字和构造逻辑崩溃风险非常高。一旦程序集升级时把内部类改名重组你的代码就会悄无声息地失效。这种场景下正确的设计是你负责的类库里显式定义一个公共入口而不是靠反射去猜内部形状。7.5 程序集名字和命名空间对不上最后聊一个隐蔽的问题程序集文件名和根命名空间不一致。这通常发生在项目改名之后.csproj文件、输出文件名、命名空间三者各走各的。比如项目文件叫CommonLib.csproj但RootNamespace却是Company.Product.Infrastructure输出的 dll 却是CommonLib.dll。外部使用者看着Infrastructure命名空间却找不到CommonLib.dll的代码来源。排查时可以直接用工具读取程序集元数据dotnet tool install -g dotnet-dump不过我更喜欢简单方式用strings看一眼 dll或者直接在调试器里用Assembly.GetName()看一下名字。总之保证项目文件名、RootNamespace、PackageId三者一致能省掉大量无意义的沟通成本。这是我被反复教育出来的教训命名看起来是小问题但在打包和分发层面它是类型的“身份证号”一旦错乱很难纠正。我个人做 .NET 项目到现在最大的体会是类型不只是源码层面的一个关键字更是部署和分发层面的一个实体。写类的时候同时要想清楚它放在哪个命名空间、哪个程序集、哪个 NuGet 包、哪个版本号下这些决定会在很久以后变成线上问题或者省心体验。特别是把 internal 当默认、把 public 当承诺、把版本号当契约这三个习惯帮我在团队协作里避开了大量“门打不开”的坑。这篇笔记基本把“类型理解”和“类型打包”串起来了下一步我打算写写依赖注入容器是如何扫描这些打包好的类型的那也是很有意思的一个话题。