Lunarium.Common
1.4.0
dotnet add package Lunarium.Common --version 1.4.0
NuGet\Install-Package Lunarium.Common -Version 1.4.0
<PackageReference Include="Lunarium.Common" Version="1.4.0" />
<PackageVersion Include="Lunarium.Common" Version="1.4.0" />
<PackageReference Include="Lunarium.Common" />
paket add Lunarium.Common --version 1.4.0
#r "nuget: Lunarium.Common, 1.4.0"
#:package Lunarium.Common@1.4.0
#addin nuget:?package=Lunarium.Common&version=1.4.0
#tool nuget:?package=Lunarium.Common&version=1.4.0
Lunarium.Common
Lunarium 项目的公共类库(shared class library),用于存放跨项目复用的通用类型与工具代码。
环境要求
- .NET SDK 10.0 或更高版本(可同时构建 net8.0 / net9.0 目标)
- 目标框架:
net8.0、net9.0、net10.0(多目标)- .NET 9 及以上使用
System.Threading.Lock,.NET 8 自动退回object加锁(条件编译) - 运行单元测试需本机具备对应运行时(测试项目自身仅
net10.0)
- .NET 9 及以上使用
项目结构
Lunarium.Common/
├── src/Lunarium.Common/ # 目录名 = 程序集名;tests/ 与之并列
│ ├── Lunarium.Common.csproj # 类库项目文件(默认 glob 包含本目录下全部 .cs)
│ ├── Alphabet.cs # Lunarium.Common
│ ├── EnvVar.cs # Lunarium.Common
│ ├── Net/ # Lunarium.Common.Net
│ ├── UID/ # Lunarium.Common.UID
│ ├── Debounce/ # Lunarium.Common.Debounce
│ └── Caching/ # Lunarium.Common.Caching(含 CacheJsonContext 源生成上下文)
├── tests/Lunarium.Common.Tests/ # xUnit 单元测试(靠 InternalsVisibleTo 直接测 internal 类型)
├── Lunarium.Common.slnx # 解决方案文件(.NET 10 新格式,含 /src/ 与 /tests/ 解决方案文件夹)
├── .gitignore # 标准 .NET 忽略规则
├── LICENSE # Apache License 2.0
└── README.md
模块
| 模块 | 命名空间 | 说明 |
|---|---|---|
Alphabet |
Lunarium.Common |
常用字符集常量(Base16/32/36/62、防混淆、密码字符集等) |
EnvVar |
Lunarium.Common |
环境变量包装:读取 / 写入 / 删除 + 类型化解析(As<T>、AsEnum、宽松布尔字面量) |
Net |
Lunarium.Common.Net |
代理配置 ProxyUrl(转换为 IWebProxy / Uri);通用环境 URL EnvUrl(转换为 Uri) |
UID |
Lunarium.Common.UID |
NanoID 无取模偏差 ID 生成(可直接输出 UTF-8 字节)、LocalSnowflake 无锁单机雪花 ID |
Debounce |
Lunarium.Common.Debounce |
脏状态防抖 DirtyDebouncer 与取消令牌防抖 CtsDebouncer |
Caching |
Lunarium.Common.Caching |
并发内存 KV + 防抖异步落盘(CacheStore);单文档泛型存储(JsonDocumentStore<T>) |
所有模块均为同一程序集内的并列工具集,零外部 NuGet 依赖(仅使用 BCL)。不拆分独立项目—— 仅当某模块需引入其他模块无法接受的重依赖、或需独立发版时才考虑拆分。
环境变量读取(EnvVar)
// 读取:未设置或值为空白时返回 null;必须存在时用 GetRequired(否则抛异常)
string? port = EnvVar.Get("PORT")?.Value;
// 候选名按优先级依次尝试,第一个命中的生效
EnvVar? proxy = EnvVar.GetAny("http_proxy", "HTTP_PROXY");
// 写入 / 删除:名称与值都会被裁剪首尾空白
EnvVar.Set("MODE", "production");
EnvVar.Unset("MODE");
// 类型化解析:泛型走 IParsable<T>(固定不变文化),失败返回默认值 / 抛异常
int timeout = EnvVar.GetRequired("TIMEOUT").As(30);
DayOfWeek day = EnvVar.GetRequired("DAY").AsEnum(DayOfWeek.Monday);
语义要点(与 BCL 的 Environment.GetEnvironmentVariable 不同之处):
Name/Value在构造时即裁剪首尾空白;Set也按裁剪后的名称与值写入。- "未设置"与"值为空白"无法区分:两者都当作未设置(
Get返回null,GetRequired抛异常)。 Set与构造函数拒绝空白值,因此写不出空字符串变量;要清空变量只能用Unset(彻底删除)。ToString()只返回变量名、不返回值,避免日志或异常信息泄露密钥(取值请用Value); 同时刻意不提供implicit operator string——否则"" + envVar这类写法会绕过脱敏取得值。As<bool>()/RequiredAs<bool>()使用宽松字面量(true/false、1/yes/on、0/no/off,大小写不敏感), 而IParsable<bool>本身只认true/false;需要自定义字面量时用AsBoolean("enabled", "disabled")。- 枚举不实现
IParsable<T>,请用AsEnum/RequiredAsEnum;后者默认requireDefined: true, 会拒绝"99"这类能解析但未定义的纯数字值。
代理可直接从环境变量构造(默认依次读取 http_proxy → HTTP_PROXY,小写优先,值同样会裁剪):
ProxyUrl? proxy = ProxyUrl.FromEnvironment(); // 也可指定变量名:FromEnvironment("MY_PROXY")
IWebProxy webProxy = proxy.ToWebProxy();
Uri uri = proxy.ToUri();
非代理场景的环境 URL 用 EnvUrl(语义更明确;没有默认变量名,也不提供 ToWebProxy):
EnvUrl? baseUrl = EnvUrl.FromEnvironment("API_BASE_URL");
Uri uri = baseUrl.ToUri();
缓存落盘位置
CacheStore 默认把数据写在 {AppContext.BaseDirectory}/CacheStore/{cacheNamespace}.json。
容器(需挂卷)、systemd 只读 root、安装在 /usr/lib 等不可写目录的场景,请显式指定可写目录:
// 默认:AppContext.BaseDirectory/CacheStore/{ns}.json
var store = new CacheStore("bot-state");
// 显式目录 → 直接落在 {dir}/{ns}.json(不会再追加 CacheStore 子目录)
var store = new CacheStore("bot-state", storageDirectory: "/var/lib/lunarium");
// 相对路径以 AppContext.BaseDirectory 为基准解析,不依赖进程当前目录
var other = new CacheStore("other", storageDirectory: "data");
CacheStore.DataDirectoryName(internal)只作为缺省目录名;进程内以解析后的完整文件路径去重,
同名命名空间落在不同目录下互不影响。测试通过向构造函数注入临时目录保证隔离,不要在测试里复刻路径约定。
单文档存储(JsonDocumentStore<T>)
当要持久化的是「一个完整的强类型文档」(配置、状态等)而不是键值对时,使用 JsonDocumentStore<T>:
// 消费方提供源生成的类型信息(AOT/裁剪安全);缩进、编码器、注释处理等选项也由该上下文决定
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(TicketConfig))]
internal partial class TicketConfigJsonContext : JsonSerializerContext;
await using var store = new JsonDocumentStore<TicketConfig>(
"ticket-config",
TicketConfigJsonContext.Default.TicketConfig,
storageDirectory: "/var/lib/lunarium");
TicketConfig config = store.Read(); // 深拷贝快照,可直接使用/修改
store.Set(config); // 保存快照并触发 5 秒防抖落盘
store.Update(c => { c.TicketCd = 2; return c; }); // 锁内原子更新
await store.FlushAsync(); // 立即落盘(DisposeAsync 也会自动刷盘)
语义与约定:
- 快照语义:
Read/Set/Update都通过 JSON 往返做深拷贝,调用方持有的实例与存储内部状态互不影响; 因此[JsonIgnore]成员不参与快照与落盘(本存储以 JSON 中的内容为准)。 - 不做业务校验:校验是调用方策略(加载后校验、更新前校验)。不要放在保存阶段——防抖落盘是后台任务,
失败只能进
logError,调用方无法同步感知。 - 落盘位置:
{storageDirectory ?? AppContext.BaseDirectory/JsonDocumentStore}/{documentName}.json; 与CacheStore共享进程级路径注册表,同一个文件不允许被任一存储类型的两个实例同时占用。 - 损坏处理:与
CacheStore一致(CacheFileCorruptException;allowCorruptRebuild必须同时提供logError)。 - AOT:只使用
JsonSerializer的JsonTypeInfo<T>重载,调用方必须传入源生成上下文产出的类型信息。
常用命令
# 在仓库根目录执行(根目录的 .slnx 会定位到 src 下的项目)
# 还原依赖
dotnet restore
# 生成(产物位于 src/Lunarium.Common/bin/ 下)
dotnet build
# 只构建单个项目
dotnet build src/Lunarium.Common/Lunarium.Common.csproj
# 打包(同时产出 .nupkg 与 .snupkg 符号包)
dotnet pack -c Release
AOT / 裁剪兼容
本库已开启 <IsAotCompatible>true</IsAotCompatible>,可以安全用于 Native AOT(PublishAot)
与 trimming(PublishTrimmed)应用。
已实测:以 PublishAot=true 发布(dotnet publish -c Release -r linux-x64)得到原生可执行文件并可正常运行,
Cache / CacheStore 的序列化与落盘重载、NanoID、LocalSnowflake、DirtyDebouncer / CtsDebouncer 均无异常。
新增需要 JSON 序列化的类型时必须同步登记
Cache 的 JSON 元数据由源生成器在编译期静态产出(见 Caching/CacheJsonContext.cs)。
任何库内需要被 JsonSerializer 序列化 / 反序列化的类型,都必须登记到 CacheJsonContext:
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(Cache))]
[JsonSerializable(typeof(新类型))] // ← 忘登记不会编译失败,只会在 AOT / 裁剪后运行时才报错
internal partial class CacheJsonContext : JsonSerializerContext;
JsonDocumentStore<T>不在此列:文档根类型位于消费方程序集,由调用方自己的JsonSerializerContext提供JsonTypeInfo<T>(见上文示例),库内不需要也无法登记。
忘记登记属于「编译期无感、运行时炸」的静默失败:库本身编译通过、无任何告警,
但 AOT / 裁剪后的应用会抛
InvalidOperationException: Reflection-based serialization has been disabled for this application.。
禁止的写法
// ✗ 这两个泛型重载挂了 RequiresUnreferencedCode / RequiresDynamicCode,
// 即使给 JsonSerializerOptions 配了 TypeInfoResolver,IL2026 / IL3050 也照样报
JsonSerializer.Serialize(value, options);
JsonSerializer.Deserialize<T>(json, options);
// ✓ 只能走源生成的 JsonTypeInfo
JsonSerializer.Serialize(value, JsonContext.Cache);
JsonSerializer.Deserialize(json, JsonContext.Cache);
其他同类禁忌:Activator.CreateInstance、Type.GetType、MakeGenericType、Expression /
DynamicMethod、Assembly.Load;Enum 的字符串互转与 Regex(非源生成)在 AOT 下也需格外小心。
分析器是常驻的
IsAotCompatible=true 会隐含 IsTrimmable=true,并在每次 dotnet build(含 IDE)中启用
IL2026 / IL3050 等分析器,因此上面那些写法在写下的当次构建就会被拦下:
# 无需任何额外参数,本库自检就是普通构建
dotnet build
同时程序集会带上 IsTrimmable / IsAotCompatible 元数据,消费者裁剪本库时不会产生额外告警。
注意该设置只开启分析器与标记,不会让库自身被 AOT 编译(类库不支持 PublishAot),
真正的 AOT 编译只由最终的可执行项目触发。
测试项目另通过 <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
关闭了 System.Text.Json 的反射回退;CacheStore 与 JsonDocumentStore<T> 的全部读写测试都在该模式下运行。
一旦误用反射序列化,测试会立即失败,而不是等到 AOT 运行时才抛
Reflection-based serialization has been disabled for this application.。
Encoder 需经由 options 实例传递(易踩)
[JsonSourceGenerationOptions] 没有 Encoder 参数(写了会 CS0246);
「非 ASCII 不转义」只能通过构造上下文时传入的 JsonSerializerOptions 实例实现,
且该实例必须静态复用(上下文按 options 实例缓存元数据,首次使用后不可再修改):
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly CacheJsonContext JsonContext = new(JsonOptions);
约定
- 启用
ImplicitUsings与Nullable(见Lunarium.Common.csproj)。 - 命名空间根为
Lunarium.Common,子模块使用Lunarium.Common.<Module>;目录结构与命名空间对齐, 即src/Lunarium.Common/<Module>/Xxx.cs对应namespace Lunarium.Common.<Module>;。 - 新增模块时只在
src/Lunarium.Common/下建一个同名目录,不新建项目。 - 代码统一使用文件范围命名空间(file-scoped namespace)与中文 XML 文档注释。
- 命名空间不得与其中的类型同名(例如曾经的
Lunarium.Common.CacheStore命名空间 +CacheStore类型)。 否则任何位于Lunarium.Common.*之下的代码引用该类型时会报CS0118: 'X' is a namespace but is used like a type: 简单名查找会自内向外逐层查找命名空间成员,在Lunarium.Common这层先撞上同名命名空间后 就不再继续看using导入的同名类型了。该模块命名空间因此改为Lunarium.Common.Caching。 - 改动涉及 JSON 序列化时,先读上面的「AOT / 裁剪兼容」一节。
- 涉及落盘路径时,先读「缓存落盘位置」一节:路径约定只在库内维护一处,测试一律注入临时目录。
许可证
本项目基于 Apache License 2.0 授权,Copyright © 2026 Cyanflower。
在遵守许可条款的前提下,可用于商业与非商业项目;再分发时请保留 LICENSE 与本文件的版权声明。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.