.NET 共享包存储(Shared Package Store)设计全解析:dotnet store 布局、发布过滤与主机探测
【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime
本文以 dotnet/runtime 仓库中的官方设计文档 DotNetCore-SharedPackageStore.md 为主体,结合仓库内主机(host)层真实实现展开,系统讲解 .NET 共享程序集存储(Shared Package Store)的目录布局、dotnet store命令、基于profile.xml的发布过滤机制,以及运行时如何通过DOTNET_SHARED_STORE环境变量与探测优先级查找共享程序集。读完本文,你将掌握如何为云主机、应用托管平台搭建机器级共享包缓存、如何把应用发布为“可瘦身”的便携应用,并理解.deps.json、runtimeconfig.json与主机探测之间的底层协作关系。
一、为什么需要共享包存储:问题与设计动机
为了让所有机器级(machine-wide)的 .NET Core 应用能够共享程序集,需要一个集中的共享程序集存储。它的核心价值有两点:
- 应用瘦身(trimming):应用可以将共享程序集从自身发布目录中剔除,不再需要随身携带它们;
- 集中维护:共享程序集由运行时安装包或托管平台统一安装、统一优化(如 crossgen 预编译),应用直接复用,既节省磁盘又缩短启动时间。
设计文档明确指出,这套机制由三部分组成:
- 一个集中式的共享程序集存储(即包存储)及其查找(lookup)机制,供应用在开发阶段和部署阶段复用;
- 一组增强
dotnetCLI 的命令,用于编排(compose)共享包存储条目,核心是dotnet store; - 一套发布机制,让应用在
dotnet publish时按需过滤(filter)程序集,从而减少磁盘占用。
从实现角度看,这套机制的生命周期贯穿三个环节:dotnet store生成布局 → 通过DOTNET_SHARED_STORE环境变量接入运行时 → 主机(host)在解析.deps.json资产时按优先级探测共享存储目录。下面逐层展开。
二、包存储目录布局:全局安装目录与 dotnet 相对目录
包存储可以是全局系统级文件夹,也可以是dotnet.exe 相对文件夹,两者对应不同的安装与维护策略。
全局(Global)存储
- 位置位于
dotnet根目录下(Windows 上位于C:\Program Files (x86)\内),见下方布局图; store/install目录下的包只允许通过平台安装器(MSI、pkg、deb、apt-get 等)安装;- 由
dotnet store命令编排出来的包布局(后文详述)预期被直接解压(unzip)到store文件夹中——注意,这个解压步骤是手动操作。
dotnet 根目录布局示意
- dotnet.exe - shared - netcoreapp2.0 + 2.0.0-preview2-00001 - store - install + refs + netcoreapp2.0 + netcoreapp2.1 + refs + netcoreapp2.0 + netcoreapp2.1其中:
shared/netcoreapp2.0/<版本>是共享框架(Shared Framework,即Microsoft.NETCore.App)的经典目录;store/install存放平台安装器安装的引用程序集(refs)与各 TFM 目录;store根下(refs、netcoreapp2.0、netcoreapp2.1等)则是dotnet store输出并手动解压得到的运行时共享包布局。
文档特别说明:netcoreapp*文件夹内部的布局采用NuGet 缓存布局(NuGet cache layout),即{package-name}/{package-version}/{asset-path}的层级结构,这样主机在探测时可以直接复用与 NuGet 缓存一致的相对路径拼接规则。
三、使用dotnet store编排运行时共享包存储
3.1 命令的定位与目标用户
dotnet store用于编排共享包存储的布局。设计文档预期两类用户会使用它:
- 托管提供商(hosting providers,如 Antares 云服务):用它预热(prime)服务器,提前把共享包布局部署到每台机器;
- 框架作者(framework authors):用它制作预优化(pre-optimized)的包归档,即压缩归档布局分发给用户。
3.2 清单文件:msbuild 格式的 packages.xml
dotnet store的输入是一份包名与版本列表,以 XML 形式给出。文档强调:packages.xml必须是 msbuild 格式,因为它是接入 SDK 其余功能的入口点("it forms the entry point from which the rest of the SDK's functionality can be accessed")。
文档给出的 Roslyn 示例:
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis" Version="1.3.2" /> <PackageReference Include="Microsoft.CodeAnalysis.VisualBasic" Version="1.3.2" /> <PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.Features" Version="1.3.2" /> </ItemGroup> </Project>托管提供商会创建一份与自家托管环境中将要共享的包一一对应的packages.xml,然后交给dotnet store。该文件既可以位于文件系统上,也可以来自一个 URL(文档示例中发布命令就出现了https://asp.net/core/dev/1.2.0/profile.xml形式的远端清单)。
3.3 命令语法与参数说明
dotnet store --manifest packages.xml --framework netcoreapp2.0 [--output C:\Foo] --runtime win7-x64 --framework-version 2.0.0-preview2-00001 [--no-optimize]各参数的完整语义(原文档参数表):
| 参数 | 说明 |
|---|---|
--framework | 指定该包存储适用的目标框架名字(TFM),例如netcoreapp2.0;该值用于上文的共享包布局目录名 |
--output | 创建包存储的输出目录;默认值为%USERPROFILE%\.dotnet(Windows)或~/.dotnet(Linux/macOS) |
--skip-optimization | restore 之后不对程序集执行 crossgen(优化是默认行为) |
--runtime | 这些程序集将要运行的目标平台运行时标识符(RID),如win7-x64 |
--framework-version | 运行这些程序集所使用的Microsoft.NETCore.App包版本,如2.0.0-preview2-00001 |
注意文档中命令示例使用--no-optimize,而参数表里写作--skip-optimization,二者表达的是同一开关的不同历史命名;核心语义是“优化(crossgen 预编译)默认开启,可显式跳过”。
3.4 优化流程与输出布局
当指定--optimize(即默认开启优化)时,dotnet store会先在临时文件夹中把所有托管资产预编译(crossgen)为原生代码,再复制到输出目录。使用的 crossgen 工具来自--framework-version所指定Microsoft.NETCore.App的闭包(closure)内获取的那个版本——这保证了预编译结果与目标运行时版本严格匹配。
如果未指定--output,默认输出到~/.dotnet或%USERPROFILE%\.dotnet\。最终输出资产文件的布局为:
$HOME/.dotnet/packages/{tfm}/{package-name}/{package-version}/{asset-path}该输出目录通过添加到DOTNET_SHARED_STORE环境变量被运行时消费(探测优先级见后文第五节)。
四、使用共享包构建应用:从项目编写到发布过滤
4.1 核心思路:把“便携/独立”的决策推迟到发布时刻
当前构建共享程序集应用的机制是:不在项目文件中指定 RID,此时采用便携应用模型(portable app model),属于Microsoft.NETCore.App的程序集在dotnet安装根目录下查找。
引入共享包存储后,应用获得了从发布输出中过滤任意一组包的能力。因此,应用是“便携”还是“独立”(standalone)的决策,不再在项目编写(authoring)时做出,而是在发布(publish)时做出。
4.2 项目编写(Project Authoring)
默认把Microsoft.NETCore.App视作始终指定了type: platform,因此用户无需显式指定 RID。相应地,在 csproj 中通过<RuntimeIdentifier/>标签指定 RID 将被视为错误(ERROR)。
4.3 dotnet restore
因为 RID 要到发布时刻才可用,所以 restore 阶段应用被当作“当今的便携应用”处理,执行一次常规 restore。
4.4 dotnet build
dotnet build应把任何项目都当作便携应用处理,并生成带有Microsoft.NETCore.App框架项的runtimeconfig.json。此外,runtimeconfig.json还应包含 TFM 字段:
"runtimeOptions": { "tfm": "netcoreapp2.0", "framework": { "version": "2.0.0", "name": "Microsoft.NETCore.App" } }文档特别对比了新旧行为差异(针对 csproj 中指定了<RuntimeIdentifier/>的应用):
- 当前行为(Current Behavior):从 NuGet 缓存中挑取
M.N.A程序集,无法利用共享Microsoft.NETCore.App提供的优化; - 新行为(New Behavior):
M.N.A程序集取自共享框架,其余程序集取自共享包存储或 NuGet 缓存。
此外,dotnet build可以利用dotnet根目录下store/install/目录中可用的refs文件夹,从而支持离线 restore-build 场景。设计文档说明:虽然未来打算增强dotnet build对引用程序集的处理,但本设计范围内只聚焦运行时程序集(runtime assemblies)。
4.5 dotnet publish:基于 profile.xml 的过滤发布
dotnet publish将被增强为支持一个以 XML 表示的过滤配置文件(filter profile file)。该文件显式列出所有需要从发布输出中剔除的资产包。文档给出多种应用类型的发布示例:
发布便携应用:
dotnet publish为当前 RID 发布独立应用:
dotnet publish --standalone为 win7-x64 发布独立应用并按 profile 过滤:
dotnet publish --runtime win7-x64 filter https://asp.net/core/dev/1.2.0/profile.xml为 win7-x64 发布便携应用并按 profile 过滤:
dotnet publish filter https://asp.net/core/dev/1.2.0/profile.xml发布“Windows 级便携应用”(即 RID 特定的便携应用)——例如过滤掉所有非 Windows RID,但把 bin 目录按 Windows RID(如 win8、win7、win10)拆分:
dotnet publish filter https://asp.net/core/dev/1.2.0/win.profile.xml若要串联多个 profile,只需重复指定多次filter。
需要特别理解profile.xml的语义边界:它指定要从dotnet publish输出中过滤掉的精确 RID 特定或 IL 包——即物理文件会被从发布输出中剔除;但deps.json(逻辑资产清单)仍然保留profile.xml中列出的条目。这一点至关重要:它解释了为什么运行时在探测时能够“知道”某个资产本该来自共享存储——deps.json中仍然记录着该资产的逻辑信息。
五、主机探测:共享存储如何被运行时消费
5.1 探测顺序(probe precedence)
原文档指出,dotnet run以及dotnet publish之后的应用激活,都由主机按照 host-probing.md 中描述的顺序进行探测。该文档给出的按优先级排序的探测路径列表(先尝试的排前面)为:
- Servicing 路径(仅用于
serviceable: true的资产,基础路径 Windows x64 为%ProgramFiles(x86)%\coreservicing、Windows x86 为%ProgramFiles%\coreservicing、Linux/Mac 为$CORE_SERVICING,其下分<base>/|arch|的 NI 探测路径与<base>/pkgs的常规探测路径); - 应用(或框架)目录;
- 框架目录(从高层框架向低层框架,应用视为最高层);
- 共享存储路径(Shared store paths):
$DOTNET_SHARED_STORE/|arch|/|tfm|—— 环境变量可包含多个路径,每个都会被追加|arch|/|tfm|后作为探测路径;- 若应用通过
dotnet.exe执行,则使用 dotnet.exe 所在目录的相对路径:<dotnet.exe path>/store/|arch|/|tfm|;
- 附加探测路径:
--additionalprobingpath命令行参数,以及.runtimeconfig.json/.runtimeconfig.dev.json中为应用和每个框架(从高层到低层)指定的additionalProbingPaths。
文档还提示:就探测而言,框架依赖应用(framework-dependent)与自包含(self-contained)应用的主要区别在于,自包含应用没有任何框架依赖,因此所有资产(包括通常来自框架的程序集)都在应用目录中探测。
5.2 源码级实现:shared_store.cpp
仓库中的实际实现位于 src/native/corehost/hostpolicy/shared_store.cpp,完整印证了设计文档的描述:
- 常量定义:
RUNTIME_STORE_DIRECTORY_NAME为store,SHARED_STORE_ENV为DOTNET_SHARED_STORE; get_paths(tfm, host_mode, host_path)首先处理旧版兼容:MNA < 1.1.*的runtimeconfig.json不包含 TFM 属性,此时tfm为空,直接返回空列表(不探测共享存储);get_env_dirs读取DOTNET_SHARED_STORE环境变量,按路径分隔符(PATH_SEPARATOR)切分成多个路径,对每个路径调用pal::fullpath规范化后,依次追加arch与tfm目录并加入候选列表——这正对应文档中$DOTNET_SHARED_STORE/|arch|/|tfm|的规则;- 仅当
host_mode == host_mode_t::muxer(即通过dotnet.exemuxer 启动)时,才会把dotnet.exe所在目录下的store/<arch>/<tfm>追加为候选路径——即“dotnet.exe 相对共享存储文件夹”。
5.3 源码级实现:deps_resolver.cpp 中的探测装配
src/native/corehost/hostpolicy/deps_resolver.cpp 中setup_probe_config的装配顺序与 host-probing.md 完全一致:
// Servicing NI probe / Servicing normal probe m_probes.push_back(probe_config_t::svc_ni(ext_ni)); m_probes.push_back(probe_config_t::svc(ext_pkgs)); // The published deps directory m_probes.push_back(probe_config_t::published_deps_dir()); // The framework locations, starting with highest level framework // ... m_probes.push_back(probe_config_t::fx(...)); // Shared store probes setup_shared_store_probes(shared_stores); // Additional probing paths m_probes.push_back(probe_config_t::lookup(probe));其中setup_shared_store_probes对每个共享存储目录做了存在性检查——只有pal::directory_exists(shared)为真的目录才会被注册为lookup类型的探测路径,并置位m_needs_file_existence_checks = true(即这些目录内的命中必须做文件存在性确认)。这保证了未安装共享包的机器上,探测不会因为不存在的目录而产生额外开销。
probe_deps_entry中按m_probes顺序逐条尝试拼接资产相对路径(如newtonsoft.json/11.0.2/lib/netstandard2.0/Newtonsoft.Json.dll)并检查文件是否存在,命中即止;全部未命中则报错。特别地,deps_resolver.cpp中定义了ManifestListMessage:
This assembly was expected to be in the local runtime store as the application was published using the following target manifest files: %s即当应用发布时使用了某个目标清单(profile)进行过滤、但运行时在本地共享存储中找不到对应程序集时,主机会给出包含该 manifest 文件名的明确错误提示——这是设计文档“deps.json仍保留被过滤条目”这一语义在运行时的落地体现。
六、典型应用场景
6.1 ASP.NET
方式一:作者制作共享包安装器(eager cache,预填充缓存)
- 从干净目录和一份
packages.xml包列表开始; - 用
dotnet store在目录中产出布局; - 将该布局打包为 MSI/pkg/deb 以及 zip;
- 发布与安装器配套的
profile.xml文件,供用户同步使用; - 开发者/部署管理员把 MSI/zips 安装到部署机器上。
方式二:让应用部署者自行缓存共享包(lazy cache,懒缓存)
- 发布可用于执行
dotnet store的packages.xml; - 发布可用于发布过滤的
profile.xml; - 部署管理员在运行应用时执行
dotnet store。
随后,开发者/部署管理员执行dotnet publish filter profile.xml产出一个不含共享组件的 ASP.NET 应用;或者安装 MSIs/zips 后直接dotnet run。
6.2 Antares(托管平台)
- Antares 用包列表配合
dotnet store在某个文件夹产出布局; - 将该文件夹链入环境变量
DOTNET_SHARED_STORE; - 从源码构建应用时,执行
dotnet run以拾取共享包; - 发布运行应用时,执行
dotnet publish filter profile.xml(使用 Antares profile)。
6.3 Roslyn(编译器工具链)
- Roslyn 包位于包目录中;
- 使用 Roslyn 的应用必须显式依赖 Roslyn,而不能通过
M.N.A(Microsoft.NETCore.App)间接获得; - 应用可以选择使用我们发布的 Roslyn profile 文件跳过这些 DLL 的发布;
- 应用部署到已安装运行时(且 Roslyn 包随运行时安装器一起安装)的另一台机器上即可运行。
6.4 托管预热(Hosting Primers)
场景 A:托管已发布的应用
- 用
dotnet store产出布局,或解压一个既有布局; - 设置
DOTNET_SHARED_STORE指向该布局; - 发布的性质取决于是否使用托管方 profile:
- 使用托管 profile:应用发布目录不包含从布局中拾取的被过滤文件;
- 不使用托管 profile:应用发布目录中的程序集覆盖布局中的同名程序集(status quo,维持现状语义)。
场景 B:从源码构建用户应用
dotnet store;- 压缩布局(zip);
- 部署到托管服务器;
- 使用 filter 发布应用。
七、设计范围内的后续工作项(Work Items)
原文档在最后列出了两个子系统的工作项,可以作为理解该功能完整落地范围的地图:
Core-Setup(运行时/主机侧)
- 将 dotnet 根目录下
Microsoft.NETCore.App的名称改为netcoreapp2.0(需保持兼容); - 把 Roslyn 程序集移出共享框架(shared framework);
- 在 Windows、Ubuntu 和 OSX 上为 Roslyn(共享)包构建安装器;
- 实现主机对用户级与全局包存储的探测;
- 在主机中纳入 TFM 变更的适配。
CLI(SDK 侧)
- 让 CLI 从共享包根目录消费 Roslyn 依赖(因为其已不再属于
M.N.A); - 让
dotnet restore以type: platform语义进行 restore; - 让
dotnet build把项目视为type: platform; - 支持
dotnet publish filter; - 完成
dotnet store的完整实现。
八、总结与最佳实践要点
结合设计文档与仓库实现,使用共享包存储的正确姿势可以归纳为四条主线:
- 布局即契约:无论全局目录还是 dotnet 相对目录,
store下都是 NuGet 缓存布局的{tfm}/{package-name}/{package-version}/{asset-path}结构,任何手工拼装都必须遵循该结构; - 清单驱动编排:用 msbuild 格式的
packages.xml声明共享包集合,dotnet store负责还原、crossgen 优化并产出布局;输出目录通过DOTNET_SHARED_STORE接入运行时; - 发布时决定形态:项目编写阶段不写 RID(
<RuntimeIdentifier/>为错误),dotnet publish时再用filter profile.xml(可多次串联)决定剔除哪些物理文件——deps.json保留逻辑条目,运行时按 host-probing.md 的优先级顺序(servicing → 应用目录 → 框架目录 → 共享存储 → 附加路径)完成解析; - 失败可诊断:若发布时依赖了共享存储、运行时却找不到程序集,主机会打印包含目标 manifest 文件名的明确错误信息(见 deps_resolver.cpp 中的
ManifestListMessage),排查时应先确认DOTNET_SHARED_STORE指向的布局与发布时使用的 profile 是否一致。
这套设计本质上是把“框架共享”的思想从Microsoft.NETCore.App推广到任意一组可共享的包:共享框架解决的是运行时自带的共享,共享包存储解决的则是“平台额外预置的共享”,两者通过统一的主机探测机制无缝衔接。
【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考