ABP VNext + Twilio:全渠道通知服务(SMS/Email/WhatsApp) 🚀
📚 目录
一、引言 ✨
📝 TL;DR
- 📡 使用 Twilio SDK + SendGrid 在 ABP VNext 中统一封装 SMS、Email、WhatsApp 通道
- ⚙️ 单例 RazorLight 引擎模板渲染,一行代码推送多渠道
- 🔄 Outbox + Quartz + ABP Unit of Work 保证事务一致与高可用异步投递
- 🔑 Azure Key Vault 管理密钥;🐇 RabbitMQ 死信;📈 Prometheus 指标;❤️🩹 Health Checks;🗑 Quartz 清理
现代应用需在用户注册、订单状态、营销推送等场景并发多渠道通知。原生调用缺少模板化、事务保障、限流重试、安全存储和监控。
二、环境与依赖 🛠️
平台:.NET 6 + ABP VNext 6.x
NuGet 包:
<PackageReference Include="Twilio" Version="5.*" /> <PackageReference Include="SendGrid" Version="10.*" /> <PackageReference Include="RazorLight" Version="2.*" /> <PackageReference Include="Volo.Abp.BackgroundJobs" Version="6.*" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.*" /> <PackageReference Include="Polly" Version="7.*" /> <PackageReference Include="DistributedLock.Core" Version="6.*" /> <PackageReference Include="Microsoft.Extensions.HealthChecks" Version="7.*" /> <PackageReference Include="prometheus-net.AspNetCore" Version="7.*" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.*" /> <PackageReference Include="Microsoft.Azure.KeyVault" Version="4.*" /> <PackageReference Include="RabbitMQ.Client" Version="7.*" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
appsettings.json
示例{ "KeyVault": { "Enabled": true, "VaultUri": "https://your-vault.vault.azure.net/" }, "Twilio": { /*…*/ }, "SendGrid": { /*…*/ }, "Notification": { "MaxParallelism": 5, "QuartzCron": "0/30 * * * * ?", "DeadLetterQueue": "notification.dlx" } }
三、系统架构概览 🏗️
四、Secrets 管理 🔑
public override void ConfigureServices(ServiceConfigurationContext context)
{
var cfg = context.Services.GetConfiguration();
if (cfg.GetValue<bool>("KeyVault:Enabled"))
{
context.ConfigurationBuilder
.AddAzureKeyVault(
new Uri(cfg["KeyVault:VaultUri"]),
new DefaultAzureCredential());
}
context.Services.Configure<TwilioOptions>(cfg.GetSection("Twilio"));
context.Services.Configure<SendGridOptions>(cfg.GetSection("SendGrid"));
// …
}
Tip:生产环境通过 Key Vault 托管密钥,安全可靠。
五、本地开发 Secrets 模拟 🏠
# 使用 .NET 用户机密存储
dotnet user-secrets init
dotnet user-secrets set "Twilio:AccountSid" "local-sid"
dotnet user-secrets set "Twilio:AuthToken" "local-token"
dotnet user-secrets set "SendGrid:ApiKey" "local-sendgrid-key"
本地调试时,
appsettings.json
不包含敏感信息,使用用户机密模拟 Key Vault。
六、客户端封装与插件化 🔌
通道抽象
public interface IChannelSender { Channel Channel { get; } Task SendAsync(string payloadJson, CancellationToken token = default); }
SmsSender
public class SmsSender : IChannelSender, ITransientDependency { public Channel Channel => Channel.SMS; private readonly TwilioOptions _opts; public SmsSender(IOptions<TwilioOptions> opts) => _opts = opts.Value; public Task SendAsync(string payloadJson, CancellationToken token = default) { var p = JsonSerializer.Deserialize<SmsPayload>(payloadJson)!; var client = new TwilioRestClient(_opts.AccountSid, _opts.AuthToken); return client.Messages.CreateAsync( to: new PhoneNumber(p.To), from: new PhoneNumber(_opts.FromPhone), body: p.Body, cancellationToken: token); } }
EmailSender、WhatsAppSender 类似,均注入
CancellationToken
支持。核心服务
public class NotificationService : INotificationService, ITransientDependency { public async Task SendAsync(Channel channel, string templateKey, object model, string to, string? subjectKey = null) { var body = await _tplMgr.RenderAsync($"{channel}/{templateKey}.cshtml", model); var subject = subjectKey == null ? null : await _tplMgr.RenderAsync($"Email/Subject_{subjectKey}.txt", model); var payload = JsonSerializer.Serialize(new { To = to, Subject = subject, Body = body }); _db.NotificationOutboxes.Add(new NotificationOutbox(channel, payload)); await _uow.SaveChangesAsync(); } }
七、模板管理 📄
public class RazorLightTemplateManager : ITemplateManager, ISingletonDependency
{
private readonly RazorLightEngine _engine;
public RazorLightTemplateManager(IConfiguration config)
{
_engine = new RazorLightEngineBuilder()
.UseFileSystemProject(Path.Combine(AppContext.BaseDirectory, "Templates"))
.UseMemoryCachingProvider()
.Build();
}
public Task<string> RenderAsync(string key, object model) =>
_engine.CompileRenderAsync(key, model);
}
/Templates
├─ SMS/Sms_VerifyCode.cshtml
├─ Email/Subject_OrderShipped.txt
├─ Email/OrderShipped.cshtml
└─ WhatsApp/Promotion.cshtml
八、事务与 Outbox 模式 🔄
public class NotificationOutbox : Entity<Guid>
{
public Channel Channel { get; set; }
public string Payload { get; set; }
public bool IsSent { get; set; }
public DateTime CreatedTime { get; set; }
public int RetryCount { get; set; } // 死信限次
public NotificationOutbox(Channel ch, string payload)
{
Id = Guid.NewGuid();
Channel = ch; Payload = payload;
IsSent = false; RetryCount = 0;
CreatedTime = DateTime.UtcNow;
}
}
Tip:新增
RetryCount
字段,避免死信循环。
九、Quartz 调度与清理作业 ⏰
9.1 投递调度
public class OutboxJobProcessor : IJob, ISingletonDependency
{
private readonly CancellationTokenSource _cts = new();
public async Task Execute(IJobExecutionContext _)
{
var batch = await _db.NotificationOutboxes
.Where(x=>!x.IsSent && x.RetryCount < 5)
.Take(NotificationConsts.MaxParallelism)
.ToListAsync(_cts.Token);
foreach (var e in batch)
{
try
{
using (_latency.NewTimer())
using (await _lock.AcquireLockAsync($"lock-{e.Id}", TimeSpan.FromSeconds(30)))
{
await Policy.WrapAsync(
Policy.BulkheadAsync(NotificationConsts.MaxParallelism, int.MaxValue),
Policy.RateLimitAsync(10, TimeSpan.FromSeconds(1)),
Policy.Handle<Exception>().WaitAndRetryAsync(
retryCount:3, sleepDurationProvider: i=>TimeSpan.FromSeconds(Math.Pow(2, i)),
onRetry: (ex, _, i, _) => {
e.RetryCount++;
_logger.LogWarning("Outbox {Id} 第 {Count} 次重试", e.Id, i);
})
).ExecuteAsync(ct=> ProcessAsync(e, ct), _cts.Token);
}
_total.Inc();
}
catch (Exception ex)
{
_logger.LogError(ex, "Outbox({Id}) 失败,转死信队列", e.Id);
_rabbit.BasicPublish("", _opts.DeadLetterQueue, null, Encoding.UTF8.GetBytes(e.Payload));
}
}
}
public Task StopAsync() => Task.Run(() => _cts.Cancel());
private async Task ProcessAsync(NotificationOutbox e, CancellationToken token)
{
var sender = _senders.First(s=>s.Channel==e.Channel);
await sender.SendAsync(e.Payload, token);
e.IsSent = true;
await _db.SaveChangesAsync(token);
}
}
9.2 清理作业
public class OutboxCleanupJob : IJob, ISingletonDependency
{
public async Task Execute(IJobExecutionContext _)
{
var cutoff = DateTime.UtcNow.AddDays(-30);
var toDel = await _db.NotificationOutboxes
.Where(x=>x.IsSent && x.CreatedTime<cutoff)
.Take(1000).ToListAsync();
_db.RemoveRange(toDel);
await _db.SaveChangesAsync();
}
}
十、监控与健康检查 📊❤️🩹
// Startup.ConfigureServices
services.AddHealthChecks()
.AddSqlServer(cfg.GetConnectionString("Default"), name:"sql")
.AddRabbitMQ(cfg["Notification:DeadLetterQueue"], name:"rabbitmq")
.AddUrlGroup("https://api.twilio.com", name:"twilio")
.AddUrlGroup("https://api.sendgrid.com", name:"sendgrid");
app.UseHttpMetrics(); // Prometheus /metrics
app.UseEndpoints(e=>{
e.MapHealthChecks("/health");
e.MapMetrics();
});
十一、Twilio Webhook 与安全 🔒
[HttpPost("twilio-callback"), EnableRateLimiting("Default")]
public async Task<IActionResult> TwilioCallback(CancellationToken token)
{
var req = Request; var form = await req.ReadFormAsync(token);
var sig = req.Headers["X-Twilio-Signature"].FirstOrDefault()!;
var url = $"{req.Scheme}://{req.Host}{req.Path}";
var validator = new RequestValidator(_opts.AuthToken);
if (!validator.Validate(url, form.ToDictionary(k=>k.Key, v=>v.Value.ToString()), sig))
return Unauthorized();
var sid = form["MessageSid"].ToString();
var status = form["MessageStatus"].ToString();
var e = await _db.NotificationOutboxes
.Where(x=>x.Payload.Contains(sid)).FirstOrDefaultAsync(token);
if (e!=null)
{
e.IsSent = status=="delivered";
await _db.SaveChangesAsync(token);
}
return Ok();
}
十二、死信补偿 🎯
public class DeadLetterConsumer : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken ct)
{
var channel = _rabbit.CreateModel();
channel.BasicConsume("notification.dlx", false, new EventingBasicConsumer(channel)
{
Received = async (_, ea) =>
{
if (ct.IsCancellationRequested) return;
var payload = Encoding.UTF8.GetString(ea.Body.ToArray());
await _db.NotificationOutboxes.AddAsync(new NotificationOutbox(Channel.SMS, payload), ct);
await _db.SaveChangesAsync(ct);
channel.BasicAck(ea.DeliveryTag, false);
}
});
return Task.CompletedTask;
}
}