Skip to content

分层架构

上一篇你已经把项目跑起来了。这一篇回答一个关键问题:代码是怎么组织的,为什么这样组织? 同时也把基础设施层(Entity 体系、事务管道、异常处理)一并讲清楚——因为它们就是架构的第 5 层。

先看一个请求怎么走完

在理解目录结构之前,先跟踪一个真实的请求,看看代码经过了哪些层。以"获取文章列表"为例:

text
客户端 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 → 客户端

再看一个"创建文章"的请求:

text
客户端 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 是最快的写法,但随着接口变多,问题就来了:

cangjie
// ❌ 混在一起: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 做的事很明确:注册服务 → 搭建中间件管道 → 启动

cangjie
// 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

控制器是请求的第一站。它的职责只有三个:

  1. 接收参数并标注来源
  2. 调用 Query 或 Mediator
  3. 返回结果

不写 SQL,不写业务判断,不直接操作 DbContext。

cangjie
// 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)

忽略注解:Int64StringBool 这类基本类型如果不加注解,参数值会是默认值(0、空字符串),而不是客户端传的值。

第3层:应用层 — Application

应用层是业务逻辑的集中地,分成三条路径:

Queries(查询) — 负责读操作。本质就是把一段带条件的 SQL 查询封装成方法:

cangjie
// 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:

cangjie
// 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 的命名有规律:CreateBlogPostCommandCreateBlogPostCommandHandler。Mediator 依赖这个命名约定自动匹配。

Services(服务) — 当一段逻辑被多个 Handler 或 Controller 共用时抽取出来:

cangjie
// 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/传给客户端的 DTOentity.bat 代码生成

所有实体都继承自一个共同的抽象类 Entity

cangjie
// 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 基类,可以把 addfindByIdremove 这些通用操作提取一次:

cangjie
// 泛型仓储基类——只写一次
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 特有的
    }
}

一个实体长这样:

cangjie
// 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
}

抽象基类体系

除了 Entityabstractions/ 目录里还有三个类,每个都消除了一类重复代码:

Queries — 查询层的统一返回包装

cangjie
// 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 — 分页排序的载体

cangjie
// abstractions/QueryModel.cj
@Model
@Serialization
public open class QueryModel {
    private var _pageSize: Int64 = 10
    private var _pageIndex: Int64 = 1
    private var _orders: ?OrderModel = OrderModel()
}

所有搜索模型都继承它,列表接口的请求参数有统一的三个字段 pageIndexpageSizeorders

PageResult — 分页响应的标准格式

cangjie
// 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 }。前端封装一次分页组件,所有列表页面都能用。

text
Entity           ← 所有实体继承,统一主键
Queries          ← 所有查询类继承,统一返回包装
QueryModel       ← 所有搜索模型继承,统一分页排序参数
PageResult<T>    ← 所有列表接口返回,统一响应格式

这四个类都很短,但每个都消除了一类重复代码。

第5层:基础设施层 — Infrastructure

这一层是"技术支撑":数据库连接、依赖注入注册、事务管道、异常处理、当前用户获取。

DI 模块注册

项目用 Module 模式分模块注册服务。InfrastructureModule 管技术组件,ApplicationModule 管业务组件:

cangjie
// 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>()
    }
}
cangjie
// 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 支持挂载管道处理器,在命令执行前后插入横切逻辑。这个项目挂了一个事务管道:

cangjie
// 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()
        }
    }
}

它做的事:

text
Command 到达


是否已在事务中?

    ├── 是 → 直接执行 next()(嵌套调用,复用外层事务)

    └── 否 → beginTransaction()


            执行 next()(调用真正的 CommandHandler)

                ├── 成功 → commit()

                └── 异常 → try 块结束,transaction 析构时自动回滚

两个关键点:

1. 嵌套事务检测。 context.hasTransaction() 保证了不会"事务套事务"——如果 CommandA 内部又触发 CommandB,B 会直接复用 A 的事务。

2. try-with-resources 自动回滚。 仓颉的 try (transaction = ...) 语法保证了 transaction 离开作用域时如果没 commit 就会自动回滚。CommandHandler 里抛任何异常,数据库状态都不会半截落地。

注册方式:

cangjie
context.services
    .addMediator()
    .registerServices(PackageInfo.get("admin.application.commands"))
    .addPipelineHandler<TransactionHandler>()    // ← 挂载事务管道

全局异常处理

KnowException — 可预期的业务错误

cangjie
// abstractions/KnowException.cj
public class KnowException <: Exception {
    public init(message: String) {
        super(message)
    }
}

它和普通 Exception 的区别只有一个:语义区分

cangjie
// 业务校验失败 → 抛 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 — 全局异常兜底

cangjie
// 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 — 当前用户获取

cangjie
// infrastructure/UserAccessor.cj
public class UserAccessor {
    // 从当前 HTTP 上下文的 JWT 中解析出 userId → 查数据库得到 User
    public func getLoginUser(): User { ... }
    public func getUserId(): Int64 { ... }
}

所有需要"当前登录用户"的地方都通过它获取,而不是各自去解析 HTTP Header 或 JWT Claim。详见 认证与用户

MapperUtilities — 对象映射

cangjie
// 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:

cangjie
let entity = MapperUtilities.map<BlogPost>(request)
// CreateBlogPostCommand → BlogPost,字段名相同的自动复制

请求流总结

把上面五层串起来,就是两条清晰的路径:

text
读路径(查询):
  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 做好了,应用层代码更简洁。