分层架构
上一篇你已经把项目跑起来了。这一篇回答一个关键问题:代码是怎么组织的,为什么这样组织? 同时也把基础设施层(Entity 体系、事务管道、异常处理)一并讲清楚——因为它们就是架构的第 5 层。
先看一个请求怎么走完
在理解目录结构之前,先跟踪一个真实的请求,看看代码经过了哪些层。以"获取文章列表"为例:
客户端 POST /api/Blog/getBlogPostList { pageIndex: 1, pageSize: 10 }
│
▼
┌─────────────────┐
│ BlogController │ 收到请求,拿到 BlogPostSearchModel
│ getBlogPostList │ 调用 queries.getBlogPostList(model)
└────────┬────────┘
│
▼
┌─────────────────┐
│ BlogQueries │ 基于 VBlogPost 视图,加上搜索条件
│ getBlogPostList │ context.vBlogPosts.whereIf(...).toPageResult(model)
└────────┬────────┘
│
▼
┌─────────────────┐
│ AdminDbContext │ 发送 SQL,拿到结果
│ vBlogPosts │ SELECT * FROM v_blog_post WHERE ...
└────────┬────────┘
│
▼
┌─────────────────┐
│ MariaDB │ 返回数据行
└────────┬────────┘
│
▼ 原路返回
PageResult<VBlogPost> → JSON → 客户端再看一个"创建文章"的请求:
客户端 POST /api/Blog/createBlogPost { title: "新文章", content: "..." }
│
▼
┌──────────────────┐
│ BlogController │ 收到 CreateBlogPostCommand
│ createBlogPost │ mediator.send(command)
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Mediator │ 找到 CreateBlogPostCommandHandler
│ (中介者管道) │ 经过 TransactionHandler(事务管道)
└────────┬─────────┘
│
▼
┌──────────────────────────┐
│ CreateBlogPostCommandHandler │ 校验 → 生成 slug → 填充字段 → DbContext
│ handle(request) │ context.blogPosts.add(entity)
└────────┬─────────────────┘
│ context.saveChanges()
▼
┌──────────────────┐
│ MariaDB │ INSERT INTO blog_post ...
└──────────────────┘你会发现:读请求和写请求走的是两条不同的路。这是有意设计的。
为什么要读写分离
把所有逻辑都塞进 Controller 是最快的写法,但随着接口变多,问题就来了:
// ❌ 混在一起:Controller 里写 SQL
public func getList(@FromQuery keyword: String, @FromQuery page: Int64) {
let param = DbParameters()
param.add("keyword", keyword)
let list = context.blogPosts.params(param).whereIf("...").toList()
// 50 行之后...
let entity = context.blogPosts.find(id)
entity.title = newTitle
context.saveChanges()
// 读和写全堆在一起
}问题在于:
- 读操作不需要事务,写操作需要。混在一起要么全加事务(浪费),要么不加(风险)。
- 读的条件(
whereIf)分散在各处,字段名改了要全局搜索。
这个项目采用的方案是 轻量 CQRS 风格:
| 操作 | 入口 | 走谁 | 有没有事务 |
|---|---|---|---|
| 读(查询) | Controller → Queries | 直接调用 | 无 |
| 写(命令) | Controller → Mediator → CommandHandler | 中介者派发 | 有(TransactionHandler) |
读路径简单直接,写路径通过 Mediator 统一管理,方便挂事务、日志这类管道。
五层结构,逐层看
按照从外到内的顺序,看看每层具体负责什么。
第1层:宿主入口 — main.cj
整个应用的启动点。main.cj 做的事很明确:注册服务 → 搭建中间件管道 → 启动。
// main.cj — 骨架
main(args: Array<String>): Int64 {
let builder = WebHost.createBuilder(args)
// 注册服务
builder.services.addCors { ... }
builder.services.addAuthentication(JwtBearerDefaults.Scheme).addJwtBearer { ... }
builder.services.addAuthorizationBuilder().addPolicy("default") { ... }
builder.services.addOpenApi()
builder.services.addHealthChecks()
// 注册模块(Controllers、Application、Infrastructure)
builder.registerModule<ControllerModule>()
builder.registerModule<ApplicationModule>()
builder.registerModule<InfrastructureModule>()
let host = builder.build()
// 中间件管道——顺序很重要
host.use<ExceptionHandlerMiddleware>() // 最外层:兜住所有异常
host.useHealthChecks("/health")
host.useFileServer() // 托管静态文件
host.useRouting()
host.useCors("default")
host.useAuthentication() // 解析 JWT
host.useAuthorization() // 校验权限
host.mapControllers() // 匹配路由,执行 Action
.requireAuthorization("default")
host.run()
}你不必现在理解每一行。知道"注册"和"使用"的区别就行:
add*和register*:往 DI 容器里放东西(好比准备好工具箱)use*和map*:搭中间件管道(好比决定工具的调用顺序)
第2层:控制器 — Controller
控制器是请求的第一站。它的职责只有三个:
- 接收参数并标注来源
- 调用 Query 或 Mediator
- 返回结果
不写 SQL,不写业务判断,不直接操作 DbContext。
// BlogController.cj
@Route["api/[controller]/[action]"]
public class BlogController <: Controller {
public BlogController(let mediator: IMediator, let queries: BlogQueries, let service: BlogService) {}
// 读:直接走 Queries
@AllowAnonymous
@HttpPost
public func getBlogPostList(model: BlogPostSearchModel) {
return queries.getBlogPostList(model)
}
// 写:走 Mediator
@HttpPost
public func createBlogPost(command: CreateBlogPostCommand) {
mediator.send(command)
}
// 简单操作:走 Service
@HttpPost
public func incrementView(@FromQuery postId: Int64) {
service.incrementView(postId)
}
}一个重要的参数绑定规则:
| 注解 | 什么时候用 | 例子 |
|---|---|---|
| 无注解(默认 FromBody) | 复杂类型(class) | create(command: CreateBlogPostCommand) |
@FromQuery | 基本类型从 URL 取值 | getDetail(@FromQuery id: Int64) |
@FromForm | 登录表单 | token(@FromForm model: TokenModel) |
@FromRoute | 路由路径取值 | getBySlug(@FromRoute slug: String) |
忽略注解:
Int64、String、Bool这类基本类型如果不加注解,参数值会是默认值(0、空字符串),而不是客户端传的值。
第3层:应用层 — Application
应用层是业务逻辑的集中地,分成三条路径:
Queries(查询) — 负责读操作。本质就是把一段带条件的 SQL 查询封装成方法:
// BlogQueries.cj — 文章列表查询
public func getBlogPostList(model: BlogPostSearchModel): PageResult<VBlogPost> {
var (list, total) = context
.vBlogPosts // 从视图查询
.params(model)
.whereIf("is_public = 1") // 只查公开文章
.whereIf("author_id = @authorId", model.authorId.isSome())
.whereIf("POSITION(@search IN title) > 0", model.search.isSome())
.whereIf("category_code = @categoryCode", model.categoryCode.isSome())
.toPageResult(model) // 自动处理分页和排序
return this.data(list, total)
}Commands(命令) — 负责写操作。每个写操作是一个 Command + 一个 Handler:
// BlogCommands.cj — 创建文章
@Serialization[superType: BlogPost]
public class CreateBlogPostCommand <: IRequest<Unit> {}
// Command 本身只定义字段(通过 @Serialization 映射),不写逻辑
public class CreateBlogPostCommandHandler <: IRequestHandler<CreateBlogPostCommand, Unit> {
public CreateBlogPostCommandHandler(let context: AdminDbContext, let userAccessor: UserAccessor) {}
public func handle(request: CreateBlogPostCommand): Unit {
let loginUser = userAccessor.getLoginUser() // 获取当前用户
let entity = MapperUtilities.map<BlogPost>(request) // 从请求映射实体
entity.slug = generateSlug(context, request.title)
entity.createdAt = DateTime.now()
entity.authorId = loginUser.id
context.blogPosts.add(entity)
context.saveChanges()
}
}Command 和 Handler 的命名有规律:CreateBlogPostCommand → CreateBlogPostCommandHandler。Mediator 依赖这个命名约定自动匹配。
Services(服务) — 当一段逻辑被多个 Handler 或 Controller 共用时抽取出来:
// BlogService.cj — 增加浏览量
public class BlogService {
public BlogService(let context: AdminDbContext) {}
public func incrementView(postId: Int64): Unit {
let param = DbParameters()
param.add("postId", postId)
context.execute("UPDATE blog_post SET views = views + 1 WHERE id = @postId", param)
}
}第4层:领域层 — Domain
这一层只有数据定义,没有业务逻辑:
| 子目录 | 是什么 | 从哪里来 |
|---|---|---|
entities/ | 数据库表的一对一映射 | entity.bat 代码生成 |
views/ | 数据库视图的一对一映射 | entity.bat 代码生成 |
models/ | 传给客户端的 DTO | entity.bat 代码生成 |
所有实体都继承自一个共同的抽象类 Entity:
// abstractions/Entity.cj
public abstract class Entity {
public mut prop id: Int64
}这个看似简单的抽象类解决了三个实际问题:
1. 统一主键约定。 所有数据库表都用 id: Int64 做主键,Entity 类把这条规则写死在代码里。代码生成工具生成的每个实体类自动带上 id 字段,不会出现有的表主键叫 Id、有的叫 UserId 的情况。
2. 统一的类型约束。 有了 Entity 基类,写泛型方法时用 where T <: Entity 就能让编译器帮你约束——只有实体类型能作为 T 传入,传错类型编译不过。
3. 为仓储基类做准备。 有了统一的 Entity 基类,可以把 add、findById、remove 这些通用操作提取一次:
// 泛型仓储基类——只写一次
public open class BaseRepository<T> where T <: Entity {
protected let context: AdminDbContext
public func findById(id: Int64): ?T {
context.set<T>().find(id) // T <: Entity 保证一定有 id
}
public func add(entity: T): Unit {
context.set<T>().add(entity)
}
public func remove(id: Int64): Unit {
let entity = T()
entity.id = id
context.set<T>().remove(entity)
}
}
// BlogRepository 只写自己特有的查询,通用操作继承
public class BlogRepository <: BaseRepository<BlogPost>, IBlogRepository {
public func findPublishedByAuthor(authorId: Int64): ArrayList<BlogPost> {
// 只有这个方法是 BlogPost 特有的
}
}一个实体长这样:
// BlogPost.cj — 对应 blog_post 表,继承 Entity 获得 id 字段
public class BlogPost <: Entity {
public mut prop title: String
public mut prop slug: String
public mut prop content: String
public mut prop authorId: Int64
public mut prop categoryId: Int64
public mut prop createdAt: DateTime
public mut prop updatedAt: DateTime
}抽象基类体系
除了 Entity,abstractions/ 目录里还有三个类,每个都消除了一类重复代码:
Queries — 查询层的统一返回包装
// abstractions/Queries.cj
public abstract class Queries {
public func data<T>(data: ArrayList<T>) where T <: ISerialization<T> {
return PageResult<T>(data: data, total: data.size)
}
public func data<T>(data: ArrayList<T>, total: Int64) where T <: ISerialization<T> {
return PageResult<T>(data: data, total: total)
}
}所有 Queries 类继承它,消除 return PageResult<VBlogPost>(data: list, total: total) 这行重复代码——改为 return this.data(list, total)。
QueryModel — 分页排序的载体
// abstractions/QueryModel.cj
@Model
@Serialization
public open class QueryModel {
private var _pageSize: Int64 = 10
private var _pageIndex: Int64 = 1
private var _orders: ?OrderModel = OrderModel()
}所有搜索模型都继承它,列表接口的请求参数有统一的三个字段 pageIndex、pageSize、orders。
PageResult — 分页响应的标准格式
// abstractions/PageResult.cj
@Serialization
public class PageResult<T> where T <: ISerialization<T> {
private var _data: ArrayList<T> = ArrayList<T>()
private var _total: Int64 = 0
}所有列表接口返回同一个结构 { "data": [...], "total": 47 }。前端封装一次分页组件,所有列表页面都能用。
Entity ← 所有实体继承,统一主键
Queries ← 所有查询类继承,统一返回包装
QueryModel ← 所有搜索模型继承,统一分页排序参数
PageResult<T> ← 所有列表接口返回,统一响应格式这四个类都很短,但每个都消除了一类重复代码。
第5层:基础设施层 — Infrastructure
这一层是"技术支撑":数据库连接、依赖注入注册、事务管道、异常处理、当前用户获取。
DI 模块注册
项目用 Module 模式分模块注册服务。InfrastructureModule 管技术组件,ApplicationModule 管业务组件:
// InfrastructureModule.cj
public class InfrastructureModule <: Module {
public override func configureServices(context: ServiceConfigurationContext): Unit {
let connectionString = context.configuration.getValue<String>("connectionStrings:default")
.getOrThrow()
// 注册 DbContext
context.services.addDbContext<AdminDbContext> {
options => options.useMysql(connectionString, "mariadb")
}
// 注册当前用户访问器
context.services.addScoped<UserAccessor, UserAccessor>() {
sp => UserAccessor(sp.getOrThrow<AdminDbContext>(), sp.getOrThrow<IHttpContextAccessor>())
}
// 注册仓储
// context.services.addScoped<IBlogRepository, BlogRepository>()
}
}// ApplicationModule.cj
public class ApplicationModule <: Module {
public override func configureServices(context: ServiceConfigurationContext): Unit {
// 手动注册 Service
context.services.addTransient<BlogService, BlogService> { ... }
context.services.addTransient<UserService, UserService> { ... }
// 扫描注册所有 Queries 子类
for (typeInfo in PackageInfo.get("admin.application.queries").typeInfos) { ... }
// 注册 Mediator + Command 处理器 + 事务管道
context.services
.addMediator()
.registerServices(PackageInfo.get("admin.application.commands"))
.addPipelineHandler<TransactionHandler>()
}
}事务管道:TransactionHandler
写操作走 Mediator,Mediator 支持挂载管道处理器,在命令执行前后插入横切逻辑。这个项目挂了一个事务管道:
// application/behaviors/TransactionHandler.cj
public class TransactionHandler <: IPipelineHandler {
public TransactionHandler(let context: AdminDbContext) {}
public func handle(_: Any, next: RequestDelegate): Any {
if (context.hasTransaction()) {
return next() // ① 已经在事务里了,直接继续
} else {
var response: ?Any = None
try (transaction = context.beginTransaction()) { // ② 新开事务
response = next() // ③ 执行 CommandHandler
transaction.commit() // ④ 成功就提交
}
return response.getOrThrow()
}
}
}它做的事:
Command 到达
│
▼
是否已在事务中?
│
├── 是 → 直接执行 next()(嵌套调用,复用外层事务)
│
└── 否 → beginTransaction()
│
▼
执行 next()(调用真正的 CommandHandler)
│
├── 成功 → commit()
│
└── 异常 → try 块结束,transaction 析构时自动回滚两个关键点:
1. 嵌套事务检测。 context.hasTransaction() 保证了不会"事务套事务"——如果 CommandA 内部又触发 CommandB,B 会直接复用 A 的事务。
2. try-with-resources 自动回滚。 仓颉的 try (transaction = ...) 语法保证了 transaction 离开作用域时如果没 commit 就会自动回滚。CommandHandler 里抛任何异常,数据库状态都不会半截落地。
注册方式:
context.services
.addMediator()
.registerServices(PackageInfo.get("admin.application.commands"))
.addPipelineHandler<TransactionHandler>() // ← 挂载事务管道全局异常处理
KnowException — 可预期的业务错误
// abstractions/KnowException.cj
public class KnowException <: Exception {
public init(message: String) {
super(message)
}
}它和普通 Exception 的区别只有一个:语义区分。
// 业务校验失败 → 抛 KnowException
if (entity.authorId != loginUser.id) {
throw KnowException("无权编辑此文章")
}
// 文章不存在 → 抛 KnowException
context.blogPosts.find(id).getOrThrow { KnowException("文章不存在") }
// 用户名重复 → 抛 KnowException
if (context.users.params(param).whereIf("username = @username").any()) {
throw KnowException("用户名已存在")
}规则很简单:你预期可能发生、想要告知调用方的错误,用 KnowException。 它的 message 直接返回给客户端。
ExceptionHandlerMiddleware — 全局异常兜底
// infrastructure/middlewares/ExceptionHandlerMiddleware.cj
public class ExceptionHandlerMiddleware <: IMiddleware {
private let logger: ILogger
private let environment: IWebHostEnvironment
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
try {
next(context) // ① 执行后续中间件和控制器
} catch (ex: Exception) {
let obj = JsonObject()
obj.put("status", JsonInt(500))
if (ex is KnowException) {
// ② 业务错误:返回 message 给调用方
obj.put("description", JsonString(ex.message))
} else {
// ③ 系统错误:返回模糊信息,记录日志
obj.put("description", JsonString("服务器错误"))
if (environment.isDevelopment()) {
obj.put("stackTrace", JsonString(ex.toString()))
}
logger.error(ex, ex.message)
}
context.response.statusCode = StatusCodes.InternalServerError
context.response.writeAsJson(obj)
}
}
}三种异常,三种处理策略:
| 异常类型 | 返回给客户端 | 日志 | 适用场景 |
|---|---|---|---|
KnowException | 具体的业务提示 | 不记 | "文章不存在"、"用户名已存在" |
普通 Exception(开发环境) | 含堆栈 | 记 | 调试期定位 bug |
普通 Exception(生产环境) | 仅"服务器错误" | 记 | 不暴露内部信息 |
这个中间件放在中间件管道的第一位,意味着它包裹了整个请求管道。控制器抛异常、认证失败、路由匹配失败——所有后续中间件产生的异常,它都能接住。
UserAccessor — 当前用户获取
// infrastructure/UserAccessor.cj
public class UserAccessor {
// 从当前 HTTP 上下文的 JWT 中解析出 userId → 查数据库得到 User
public func getLoginUser(): User { ... }
public func getUserId(): Int64 { ... }
}所有需要"当前登录用户"的地方都通过它获取,而不是各自去解析 HTTP Header 或 JWT Claim。详见 认证与用户。
MapperUtilities — 对象映射
// infrastructure/MapperUtilities.cj
public class MapperUtilities {
public static func map<T>(value: ISerializable) where T <: ISerialization<T> {
let dm = value.serializeObject(_options)
return JsonSerializer.deserializeObject<T>(dm, _options)
}
}利用 Spire 的序列化能力做对象映射:先序列化为中间 DataModel,再反序列化为目标类型。CommandHandler 里用它把 Command 对象转成 Entity:
let entity = MapperUtilities.map<BlogPost>(request)
// CreateBlogPostCommand → BlogPost,字段名相同的自动复制请求流总结
把上面五层串起来,就是两条清晰的路径:
读路径(查询):
Client → Controller → Queries → DbContext → MariaDB
写路径(命令):
Client → Controller → Mediator → [TransactionHandler] → CommandHandler → DbContext → MariaDB工程选型说明
1. 查询不强制走 Mediator。 Mediator 适合需要管道的写操作(事务、日志),但查询只是"拿数据",多一层中介者只是多写样板代码。BlogQueries 直接暴露方法,Controller 直接调用。
2. 写操作统一走命令。 每个写操作都是一个 Command + Handler,好处是:
- 事务通过
TransactionHandler管道统一挂载,不用每个 Handler 里手动begin/commit - 新增横切关注点(如操作日志)只需加一个 PipelineHandler
3. 视图优先的查询策略。 列表、详情、用户资料这些读操作都查询数据库视图而不是原始表。视图已经在数据库层把 join 和 count 做好了,应用层代码更简洁。