Net8微服务实战
前言
学习杨中科老师开源项目在线英语网站微服务
1.需求
服务拆分
2.项目源码
项目 | 类 | 说明 |
---|---|---|
Peng.ASPNETCore | DistributedCacheHelper | 分布式缓存帮助类 |
MemoryCacheHelper | 内存缓存帮助类 | |
UnitOfWorkFilter | 工作单元筛选器 | |
Peng.Commons | Validators文件夹 | FluentValidation的扩展类 |
LoggerExtensions | 使用FormattableString简化日志的代码 | |
ModuleInitializerExtensions | 把服务注册代码放到各自的项目中 | |
Peng.DomainCommons | IAggregateRoot | 聚合根标识接口 |
BaseEntity、AggregateRootEntity | 领域事件的发布 | |
MultilingualString | 多语言值对象 | |
Peng.EventBus | 集成事件总线 | |
Peng.Infrastructure | BaseDbContext | 领域事件的发布 |
EFCoreInitializerHelper | 上下文的自动化注册 | |
ExpressionHelper | 简化值对象的相等性比较 | |
MediatorExtensions | 领域事件的注册 | |
MultilingualStringEFCoreExtensions | 多语言值对象的配置 | |
Peng.JWT | 使用JWT实现登录令牌 |
3.运行
3.1环境搭建
需要什么就装什么
https://www.cnblogs.com/pengboke/p/18156610
3.2升级Net8
所有程序都升级为Net8
创建Net8框架程序,然后把代码复制粘贴,升级对应的NuGet包
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
3.3appsettings
这里的配置文件我都换成了本地配置
"Cors": {
"Origins": [ "http://localhost:3000", "http://localhost:3001" ]
},
"FileService": {
"SMB": {
"WorkingDir": "e:/temp/upload"
},
"UpYun": {
},
"EndPoint": {
}
},
"ConnectionStrings": {
"Connection": "Server=localhost;Database=Peng-VNext;charset=utf8;uid=root;pwd=root;port=3306;"
},
"Redis": {
"ConnStr": "localhost"
},
"JWT": {
"Issuer": "myIssuer",
"Audience": "myAudience",
"Key": "pengpeng@123456789abcdefghijklmnopq",
"ExpireSeconds": "31536000"
},
"RabbitMQ": {
"HostName": "127.0.0.1",
"ExchangeName": "peng_event_bus",
"UserName": "guest",
"Password": "guest"
},
"ElasticSearch": { "Url": "http://elastic:OXanY7JrEElR--N0Fx4z@localhost:9200" }
3.4数据库冲突
注释一下代码:
WebApplicationBuilderExtensions
添加数据库配置
builder.Services.AddDbContext<IdDbContext>(options =>
{
string connstr = builder.Configuration.GetValue<string>("ConnectionStrings:Connection");
options.UseMySql(connstr, ServerVersion.AutoDetect(connstr));
});
3.5IMediatR冲突
注释WebApplicationBuilderExtensions的IMediatR注入
IMediatR升级Net8后,没有这个方法了。
3.6Nginx配置
我这里用的是8000端口
主要是配置Server
server {
listen 8000;
server_name localhost;
location /FileService/ {
proxy_pass http://localhost:50401/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100m;
}
location /IdentityService/ {
proxy_pass http://localhost:50402/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /Listening.Admin/ {
proxy_pass http://localhost:50403/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /Listening.Main/ {
proxy_pass http://localhost:50404/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /MediaEncoder/ {
proxy_pass http://localhost:50405/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /SearchService/ {
proxy_pass http://localhost:50406/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
}
}
3.7数据迁移
3.7.1IdentityService
IdentityService.WebAPI安装
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
</ItemGroup>
IdentityService.Infrastructure安装8.0的Mysql包
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
IdentityService.Domain
DesignTimeDbContextFactory,有些代码没看明白,先固定,保证运行
没有报错运行一下代码,正常跑起来可以用迁移命令
迁移命令
Add-Migration init
Update-Database init
迁移成功,数据库已更新
创建一个管理员
登录成功,认证服务可以正常运行了。
3.7.2FileService
FileService.WebAPI安装包并且注释全球化
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
</ItemGroup>
FileService.Infrastructure
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\FileService.Domain\FileService.Domain.csproj" />
<ProjectReference Include="..\Peng.Infrastructure\Peng.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="UpYun.NETCore" Version="1.1.0" />
</ItemGroup>
注入DB服务
//db
builder.Services.AddDbContext<FSDbContext>(options =>
{
string connstr = builder.Configuration.GetValue<string>("ConnectionStrings:Connection");
options.UseMySql(connstr, ServerVersion.AutoDetect(connstr));
});
appsettings
数据迁移
Add-Migration init
Update-Database init
3.7.3ListeningService
Listening.Domain
Listening.Infrastructure
Listening.Main.WebAPI
Listening.Admin.WebAPI
注入DB服务
appsettings
没有报错运行一下代码,正常跑起来使用迁移命令
Add-Migration init
Update-Database init
3.7.4MediaEncoderService
MediaEncoder.Domain
MediaEncoder.Infrastructure
MediaEncoder.Infrastructure
DesignTimeDbContextFactory,有些代码没看明白,先固定,保证运行
注入DB服务,然后注释掉builder.Services.AddHostedService,等数据库迁移完成再放开即可
appsettings
没有报错运行一下代码,正常跑起来使用迁移命令
Add-Migration init
Update-Database init
3.7.5SearchService.WebAPI
SearchService.Domain
SearchService.Infrastructure
SearchService.WebAPI
appsettings
3.7.6前端
淘宝镜像过期
报错:
npm ERR! request to https://registry.npm.taobao.org/XXX failed, reason: certificate has expired
更新淘宝镜像
# 清楚缓存
npm cache clean --force
# 获取镜像地址
npm config get registry
# 设置淘宝镜像地址
npm config set registry https://registry.npmmirror.com
yarn
# 设置yarn的strict-ssl
yarn config set "strict-ssl" false -g
# 安装yarn
npm install -g yarn
# 运行
yarn dev
修改前端端口号
端口号为nginx所配置的
3.7.8运行后端
修改后端启动端口
FileService.WebAPI:http://localhost:50401/
IdentityService.WebAPI:http://localhost:50402/
Listening.Admin.WebAPI:http://localhost:50403/
Listening.Main.WebAPI:http://localhost:50404/
MediaEncoder.WebAPI:http://localhost:50405/
SearchService.WebAPI:http://localhost:50406/
3.7.9完成
到此所有服务基本可以跑起来了,但是基于Net8有很多不兼容,后面慢慢学习慢慢优化。
4.文件服务
清空表数据
truncate table T_Episodes;
truncate table T_FS_UploadedItems;
添加专辑AlbumAddRequestValidator
RuleFor(x => x.CategoryId).Must((cId, ct) => dbCtx.Query<Category>().Any(c => c.Id == cId.CategoryId)).WithMessage(c => $"CategoryId={c.CategoryId}不存在");
添加EpisodeAddRequestValidator
RuleFor(x => x.AlbumId).Must((cId, ct) => ctx.Query<Album>().Any(c => c.Id == cId.AlbumId))
.WithMessage(c => $"AlbumId={c.AlbumId}不存在");
修改端口号,我没搞明白为什么http://localhost可以访问,这里需要加上nginx端口号进行转发,实际的文件服务地址端口号应该也可以。
上传
这样我的文件服务可以正常上传了,下面梳理下逻辑
上传主要是是上传到云服务器和备份服务器
MockCloudStorageClient是模拟云服务器,文件保存在wwwroot文件夹下。
SMBStorageClient是备份服务器,保存在配置的e:/temp/upload
5.认证服务
使用IdentityServer4组件,User和Role
IIdRepository认证服务仓储
实现手机和邮箱发送验证码
创建用户和重置密码的时候发布领域事件
可以同时或者选择性的把新增用户的密码短信/邮件/打印给用户
体现了领域事件对于代码“高内聚、低耦合”的追求
认证服务主要分为
-
LoginController:登录功能
-
UserAdminController:用户的基本CRUD功能
6.听力服务
分类(Category)、专辑(Album)、音频(Episode)3个实体。由于我们可以直接访问某个专辑或者某个音频,因此我们这里把这3个实体放到3个聚合之中。
建造者模式使用链式调用保证创建出来的Episode都是合法的。
把许多构造函数复制变成方法赋值看起来更清晰。
参数的合法性校验也放到Build中。
var builder = new Episode.Builder();
builder.Id(id).SequenceNumber(maxSeq + 1).Name(name).AlbumId(albumId)
.AudioUrl(audioUrl).DurationInSecond(durationInSecond)
.SubtitleType(subtitleType).Subtitle(subtitle);
字幕解析防腐层
ISubtitleParser:字幕解析接口。所有的字幕解析实现继承该接口。
JsonParser:Json字幕解析方式。
LrcParser:Lrc字幕解析方式。
SrtParser:Srt字幕解析方式。
SubtitleParserFactory:创建解析对象,通过传入名称生成解析对象。
听力服务的仓储接口IListeningRepository
听力服务的领域服务ListeningDomainService
Listening.Admin.WebAPI(听力后台服务):CategoryController、AlbumController、EpisodeController分别提供了对分类、专辑、音频进行管理的接口。
DDD原则:一个实体不应该有处于不完整状态的时刻,这样就能减少程序中对于不完整状态进行处理的代码。因此:一个没有完成转码的Episode对象就不应该存在于数据库中。
所以不通过IsReady来保存音频状态。
保存音频的时候,先把音频实体放到Redis缓存,然后发布集成事件通知转码服务进行转码。
需要转码的音频保存到Redis,转码完成后,程序再把Redis中暂存的音频数据保存到数据库中。
Episode对象一直处于完整状态,也就是数据库中的音频记录一定是可用的,代码就简单很多了。这就是DDD给系统设计带来的一个很大的好处。
转码服务会在转码开始、转码失败、转码完成等事件出现的时候,发布名字分别为MediaEncoding.Started、MediaEncoding.Failed、MediaEncoding.Completed等集成事件,因此我们只要监听这些集成事件,然后把转码状态的变化推送到前端页面即可,然后在更新Redis。
领域事件转换为集成事件:
EpisodeCreatedEventHandler:音频添加,发布事件。
EpisodeDeletedEventHandler:音频删除,发布事件。
EpisodeUpdatedEventHandler:音频修改,发布事件。
查询分类、专辑、音频的时候使用了MemoryCache缓存。
Redis分布式缓存都缓存在Redis服务器上,而MemoryCache缓存在内存上,这里没有多少服务器而且数据量不大更适合MemoryCache缓存。
还使用了local函数(本地函数),本地函数是一种嵌套在另一成员中的类型的方法。 仅能从其包含成员中调用它们。使代码结构更清晰。
前端显示字幕:根据audio标签的timeupdate事件获取标签进度的时间,然后再去sentences(字幕列表)根据startTime和endTime过滤。
const querySentence=(position)=>
{
const sentences = state.episode.sentences;
for (var i = 0; i < sentences.length; i++)
{
var sentence = sentences[i];
if (position >= sentence.startTime && position <= sentence.endTime)
{
return sentence;
}
}
};
const updateCurrentSentence=()=>{
var position = mainPlayer.value.currentTime;
var foundSentence = querySentence(position);
if (foundSentence && !sentenceEqual(foundSentence,state.currentSentence))
{
state.currentSentence = foundSentence;
}
};
音频标签
字幕列表
7.转码服务
目前只需要m4a转码,这和听力服务的字幕解析模块类似。
IMediaEncoder:转码接口。所有的转码服务实现继承该接口。
ToM4AEncoder:M4A转码服务。
MediaEncoderFactory:创建转码服务对象,通过传入名称生成解析对象。
IMediaEncoderRepository:转码服务仓储
M4A转码服务实现
使用第三方FFmpeg.NET包(也可以通过命令行(cmd)直接调用ffmpeg.exe)
var ffmpeg = new Engine(ffmpegPath);
string? errorMsg = null;
ffmpeg.Error += (s, e) =>
{
errorMsg = e.Exception.Message;
};
await ffmpeg.ConvertAsync(inputFile, outputFile, ct);//进行转码
if (errorMsg != null)
{
throw new Exception(errorMsg);
}
启动后台服务,并创建一个死循环,5s一次获取Redis中需要执行的转码任务。
首先使用RedLockNet.SERedis实现Redis分布式锁,保证任务同一个任务不会被多个转码服务执行。
获取到任务后开始执行,更改并保存任务状态为开始。
然后下载到转码服务器临时文件夹。
声明转码后的文件信息。
计算下载后的文件散列值(相同文件的散列值一样,不一样的概率很低),然后根据文件散列值个文件大小查询数据库是否存在对应的M4A文件,存在就不转码,更新到音频表。
然后使用EncoderFactory创建ToM4AEncoder转码对象进行转码,转码成功发布集成事件和领域事件。
8.搜索服务
搜索服务没什么要说的,本来想通过最新的Elastic.Clients.Elasticsearch实现,但是查询高亮会有一些问题,还是退回NEST。还是留一下Elasticsearch的安装,不过有坑的我都记录下来了。
前端