模板Id生成(Aegis.TemplateIdGenerator)
Aegis.TemplateIdGenerator 用于按照业务模板生成门诊号、住院号、检验单号、床位号等模板Id。业务代码统一依赖 ITemplateIdGenerator,默认实现适合单进程运行。
如何引入
安装 NuGet
dotnet add package Aegis.TemplateIdGenerator
启用组件
在宿主项目的 Component.deps.json 中,将 TemplateIdGenerator 加入 Services:
{
"Components": {
"Services": [
"TemplateIdGenerator"
],
"Middlewares": []
}
}
TemplateId 不提供中间件,不需要放入 Middlewares。使用 ITemplateIdGenerator、IdTemplate 等公共类型的项目直接引用当前 NuGet 包即可。
组件概览
| 字段 | 说明 |
|---|---|
| 组件名称 | 模板Id生成 |
| 真实类库 | Aegis.TemplateIdGenerator |
| 组件定位 | 按模板生成具有业务含义的字符串编号 |
| 引入方式 | 安装 NuGet,并在 Component.deps.json 中启用 TemplateIdGenerator |
| 组件声明 | TemplateIdGenerator |
| 模板来源 | 默认读取 TemplateIdGenerator:Templates;可追加代码内嵌模板和自定义模板来源 |
| 默认流水号 | 进程内内存计数器 |
| 核心接口 | ITemplateIdGenerator |
| 分布式实现 | Redis 模板Id |
什么时候要用它
适合以下场景:
- 编号由前缀、日期、流水号等固定片段组成。
- 不同业务需要使用独立模板和独立流水号。
- 业务层不应感知流水号保存在内存、Redis 还是自定义存储中。
- 保存模板前需要预览最终编号格式。
以下场景应使用其他能力:
- 无需可读规则的技术主键,应使用 GUID、雪花 ID 或数据库序列。
- 需要密码学随机性的标识,应使用安全随机源。
- 多实例需要共享流水号时,应改用 Redis 模板Id。
最小可运行路径
第一步:在 appsettings.json 中定义模板
{
"TemplateIdGenerator": {
"Templates": [
{
"Key": "OP:",
"Prefix": "OP",
"FormatString": "{Prefix}{Date}{SerialNum}",
"SerialNumberLength": 6,
"PaddingChar": "0",
"ResetByDate": true,
"IsReturnable": false,
"RecoveryTime": "00:00:00"
}
]
}
}
TemplateIdGenerator 组件会自动读取 TemplateIdGenerator:Templates,不需要在 Startup 中再次注册基础服务。
第二步:在业务服务中使用
using Aegis.TemplateIdGenerator;
public sealed class RegistrationNumberService(ITemplateIdGenerator generator)
{
public string Create()
{
return generator.Generate("OP");
}
public string[] CreateBatch(int count)
{
return generator.GenerateIds("OP", count);
}
}
业务代码只需要传入业务编码,不需要先调用 TemplateIdKey.Build。
核心概念
业务编码与模板 Key
业务代码使用稳定的业务编码定位模板:
var number = generator.Generate("OP");
var batch = generator.GenerateIds("OP", 10);
模板配置中仍使用完整 Key,例如业务编码 OP 对应 OP:。业务编码和完整 Key 均区分大小写。
已有代码可以继续传完整 Key:
var number = generator.Generate("OP:");
当单参数中包含 : 时,组件会按完整 Key 查找;不包含 : 时,先尝试旧 Key 的精确匹配,未找到后再按标准业务编码解析。
IdTemplate 字段
| 字段 | 必填 | 说明 |
|---|---|---|
Key | 是 | 模板存储 Key,例如 OP: |
Prefix | 否 | {Prefix} 的渲染值,未设置时按空字符串处理 |
FormatString | 是 | 编号格式串 |
SerialNumberLength | 是 | 流水号宽度,范围为 1–9 |
PaddingChar | 是 | 流水号左填充字符,常用 '0' |
MinSerialNumber | 否 | 最小流水号,未设置时为 1 |
MaxSerialNumber | 否 | 最大流水号,未设置时为 10^SerialNumberLength - 1 |
ResetByDate | 否 | 是否按业务日期使用独立流水号,默认 false;流水桶与日期占位符使用本次生成捕获的同一时间 |
IsReturnable | 否 | 编号能否确认、回收和复用,默认 false |
RecoveryTime | 否 | 未确认编号的回收超时,仅在 IsReturnable = true 时生效;零表示不自动回收 |
格式占位符
| 占位符 | 输出 | 示例 |
|---|---|---|
{Prefix} | Prefix 字段 | OP |
{Date} | 本次生成捕获时间的日期 yyyyMMdd | 20260803 |
{DateTime:格式} | 按指定固定宽度格式输出日期时间 | {DateTime:yyyyMMddHHmm} |
{SerialNum} | 按宽度填充的十进制流水号 | 000001 |
{Letter} | 流水号对应的字母形式 | A |
格式串可以组合字面量:
{Prefix}-{Date}-{SerialNum}
{DateTime:格式} 的格式部分必须是固定宽度格式,可以使用 yyyyMMddHHmmss 或 yyyy-MM-dd HH:mm:ss。模板应只使用上表列出的占位符,非法流水号范围或宽度会在模板加载时抛出异常。{Letter} 不能与 IsReturnable = true 同时使用;可归还模板应使用 {SerialNum}。
当 ResetByDate = true 且 IsReturnable = true 时,FormatString 必须包含能够唯一标识签发日的完整日期:可以使用 {Date},也可以使用一个同时包含 yyyy、MM、dd 的 {DateTime:...} 占位符,例如 {DateTime:yyyyMMddHHmmss}。只有年份、只有月日或只有时间均不满足要求,模板加载时会拒绝该配置。
可归还编号的生命周期
IsReturnable = true 时,推荐按以下顺序处理:
- 使用
Generate或GenerateIds领取编号。 - 业务数据持久化成功后调用
ConfirmId或ConfirmIds。 - 持久化失败时不确认,也不手动归还,等待
RecoveryTime超时后的回收扫描。 - 已确认的业务后来取消时,调用
ReleaseId主动归还。
未确认编号不要同时调用 ReleaseId,否则可能和超时回收重复归还同一个编号。自动回收要求 RecoveryTime > TimeSpan.Zero;默认内存实现每 30 秒扫描一次,编号会在超时后的下一次扫描中进入归还队列,并非到达超时时刻立即可用。
对于同时启用按日重置和归还的模板,主动归还与超时回收始终使用号码签发时的原始日期桶和流水号,不会根据执行归还或回收时的当前日期重新选择流水桶。该组合内部会避免同一签发记录重复进入归还队列,但业务侧仍应保证同一编号只主动归还一次。
生成、确认与归还
单个与批量生成
var one = generator.Generate("LAB");
var batch = generator.GenerateIds("LAB", 10);
每次 Generate 只捕获一次生成时间,流水桶选择、{Date} 和所有 {DateTime:...} 占位符都使用该时间。GenerateIds 整个批次也只捕获一次;即使执行过程中跨过午夜,整批编号仍使用同一个日期桶和相同的日期时间基准。
批量结果可能同时包含已归还编号和新流水号,因此不保证连续。批量分配不是事务操作;中途容量不足时不会返回部分结果,但此前已消费的归还编号和已推进的计数器不会回滚。
持久化成功后确认
public sealed class InpatientNumberService(ITemplateIdGenerator generator)
{
public async Task<string> CreateAsync(Func<string, Task> saveAsync)
{
var number = generator.Generate("IP");
await saveAsync(number);
generator.ConfirmId("IP", number);
return number;
}
}
对于 IsReturnable = true 且 RecoveryTime > TimeSpan.Zero 的模板,saveAsync 抛出异常时不会执行确认,编号会保留在未确认集合中,并在超时后的下一次回收扫描中进入归还队列。RecoveryTime 为零时不会自动回收。
对于已启用超时回收的可归还模板,业务数据已经提交、随后确认调用失败时,编号仍可能在超时后被回收。正式环境应通过重试、Outbox 或等价的可靠投递方式保证确认最终成功,并以业务记录中的编号作为幂等依据。
已确认业务取消后归还
var released = generator.ReleaseId("BED", bedNumber);
返回 true 表示当前模板允许归还且调用已经完成,或命中了该编号保留的历史签发状态并完成归还;返回值不代表调用方可以据此判断编号是否实际进入归还队列。ReleaseId 不是全局幂等操作,业务侧必须只归还由该模板生成且已经确认的编号,并保证同一编号只归还一次。
预览模板
IdPreviewer 复用模板解析与渲染规则,但不会读取或推进流水号状态:
var preview = IdPreviewer.Preview(
new IdTemplate
{
Key = "OP:",
Prefix = "OP",
FormatString = "{Prefix}{Date}{SerialNum}",
SerialNumberLength = 6,
PaddingChar = '0'
},
serialNumber: 25,
now: new DateTime(2026, 8, 3));
// OP20260803000025
模板来源与扩展
配置文件模板
组件默认读取宿主组合配置中的 TemplateIdGenerator:Templates。模板可以放在 appsettings.json、环境配置或 Aegis 已加载的附加配置文件中。
配置模板与其他模板来源可以同时存在。不同来源应使用互不重复的 Key,避免依赖提供程序注册顺序决定结果。
代码内嵌模板
需要由代码提供模板时,可以在宿主的 ICustomStartup.ConfigureServices 中追加:
services.AddTemplateIdGenerator(options =>
{
options.AddTemplate(new IdTemplate
{
Key = "OP:",
Prefix = "OP",
FormatString = "{Prefix}{Date}{SerialNum}",
SerialNumberLength = 6,
PaddingChar = '0',
ResetByDate = true,
IsReturnable = false
});
});
宿主仍应在 Component.deps.json 中启用 TemplateIdGenerator。这里的调用用于追加代码模板,基础服务注册本身是幂等的。
自定义模板来源
实现 IIdTemplateProvider 后,在宿主注册自定义来源:
public sealed class BillingTemplateProvider : IIdTemplateProvider
{
public IdTemplate[] GetTemplates()
{
return
[
new IdTemplate
{
Key = "INV:",
Prefix = "INV",
FormatString = "{Prefix}{Date}{SerialNum}",
SerialNumberLength = 8,
PaddingChar = '0'
}
];
}
}
services.AddIdTemplateProvider<BillingTemplateProvider>();
自定义流水号提供程序
ISerialNumberProvider 是流水号存储扩展点。自定义实现需要完整处理单号、批量、确认、归还、未确认跟踪、并发安全以及各状态操作所需的原子性。自定义实现为按日重置模板发号时,应同时实现 IGenerationScopedSerialNumberProvider,确保流水桶与日期渲染使用同一次生成时间;多实例场景优先使用现成的 Redis 实现。
怎么选择运行后端
| 维度 | 默认实现 | Redis 实现 |
|---|---|---|
| 进程数量 | 单进程 | 多实例 |
| 流水号持久化 | 不持久化 | Redis |
| 模板来源 | 配置文件、内嵌或自定义 | 集中式模板来源 |
| 模板更新 | 可通过 ITemplateManager 在当前进程更新;不自动重载配置或跨实例同步 | 支持集中更新与实例间同步 |
| 业务调用接口 | ITemplateIdGenerator | ITemplateIdGenerator |
默认内存计数器在进程重启后从模板最小值重新开始。需要重启后仍保持流水号状态、跨实例发号,或让多个实例共享并同步模板更新时,使用 Redis 模板Id。
接入后怎么确认生效
- 启动应用,确认
ITemplateIdGenerator可以从 DI 解析。 - 对同一模板连续生成两个编号,确认流水号按预期变化。
- 使用不存在的业务编码,确认抛出“模板不存在”异常。
- 重启默认实现的宿主,确认业务已经接受计数器重新初始化的语义。
- 多实例部署改用 Redis 实现后,再验证跨实例并发不重复。
边界与限制
- 默认流水号只保证单进程内并发安全,不适合多实例部署。
- 默认流水号不持久化,进程重启后会从模板最小值重新开始。
- 生成编号与业务数据库写入不在同一个事务中。
- 组件不保证严格连续无跳号,业务排序应使用独立时间或序列字段。
- 业务编码和模板 Key 区分大小写。
{Date}与{DateTime:格式}默认使用调用开始时捕获的宿主本机时间;同一次单号或批量生成不会重新读取时间。多个宿主仍需统一时区并保持时钟同步。- 当前生成、确认与归还 API 为同步调用。
- 模板格式应包含
{SerialNum}或{Letter},否则可能生成相同字符串。 ReleaseId不是幂等操作,同一编号只能归还一次。ConfirmId、ConfirmIds和ReleaseId只携带模板与最终编号,调用方必须阻止上一代签发的延迟确认或归还请求作用于已经重新签发的同一编号;严格的跨代隔离需要后续增加签发凭证。- 模板开始发号后不要修改
ResetByDate;需要切换重置策略时应创建新的模板code,避免新旧流水桶生成相同编号。
常见问题
为什么通过业务编码找不到模板
请确认模板来源中存在对应的标准 Key,例如业务编码 OP 对应 OP:,同时核对大小写和模板来源。
业务代码还需要 TemplateIdKey.Build 吗
不需要。直接调用 Generate("OP") 或 GenerateIds("OP", count)。完整 Key 调用继续兼容,主要用于已有代码和基础设施层。
业务层需要引用 Redis 扩展吗
不需要。业务层继续引用 Aegis.TemplateIdGenerator 并使用 ITemplateIdGenerator;Redis 扩展只安装在最终宿主。
升级修复版本需要修改业务调用吗
普通业务调用不需要修改。Generate、GenerateIds、ConfirmId、ConfirmIds 和 ReleaseId 的调用签名保持不变,业务代码不需要传入日期、流水桶或新的上下文参数,现有组件声明和模板配置方式也不变。
如果项目自行实现了 ISerialNumberProvider,并使用该实现生成按日重置模板,则需要实现 IGenerationScopedSerialNumberProvider;只生成非按日模板的旧实现继续兼容。
如果项目仍直接引用已经合并的 Aegis.TemplateIdGenerator.Abstractions,需要改为引用 Aegis.TemplateIdGenerator 并重新编译。自定义 ITemplateManager 实现还需要补充 ReplaceAll,用于原子替换模板快照。
当前部署不存在 ResetByDate = true 且 IsReturnable = true 的历史签发数据,因此本次升级不需要迁移配置或补录历史状态。使用 Redis 实现时的升级说明见 Redis 模板Id。
能否依赖编号判断创建时间或先后顺序
不能。按日重置、归还复用、失败与容量边界都会影响编号顺序。