Blog 实战总览
一个完整的博客项目后端。使用仓颉 + Spire + SQLSharp,采用 CQRS 风格分层架构。本指南带你从零理解这个项目是怎么搭起来的,以及你自己开新项目时可以参考什么。
架构全景
这个项目能帮你解决什么
如果你正在用 Spire 框架做项目,大概率会遇到这些问题:
- 目录不知道怎么分,controller、service、domain 到底怎么放
- 查询和命令混在一起,代码越写越难改
- 数据访问直接写在 controller 里,换个数据库就要改几十个文件
- 不知道 JWT、OpenAPI、静态文件托管怎么拼到一起
这个博客项目就是针对这些问题的参考答案。它不是单个功能的演示,而是一个完整的、可运行的系统。
技术栈
| 技术 | 用途 |
|---|---|
| 仓颉(Cangjie) | 编程语言 |
| Spire 框架 | Web 全家桶:MVC、DI、认证、序列化、Mediator |
| SQLSharp | ORM:DbContext + DbSet + LINQ 风格查询 |
| MariaDB | 关系型数据库 |
| JWT Bearer | 身份认证 |
能力矩阵
项目实现了博客系统的全部核心能力,每一项都对应一个真实可调的 API。
| 模块 | 能力 | 读写路径 | 关键实现 |
|---|---|---|---|
| 文章 | 列表(分页+筛选+排序)、详情、创建、更新、删除 | 读走 Queries + 视图,写走 Mediator + 事务 | VBlogPost 视图 join 了作者、分类、统计 |
| 评论 | 列表、创建、删除,支持嵌套回复 | 读走 Queries + 视图,写走 Mediator | 两级结构:parent_id 实现回复 |
| 收藏 | 切换状态、检查状态 | 写走 Mediator(Toggle 模式) | 调一次收藏,再调一次取消 |
| 关注 | 切换状态、检查状态 | 写走 Mediator(Toggle 模式) | 与收藏完全相同的模式 |
| 分类/标签 | 获取全部 | 纯读取,直接走 Queries | 文章列表筛选的数据源 |
| 用户 | 注册、资料、文章列表、编辑、注销 | 读写混合 | 注册不登录,编辑需登录 |
| 认证 | 登录获取 JWT、获取当前用户 | 走 ConnectController | JWT sub claim 存 userId |
| 系统 | 健康检查、OpenAPI 文档、静态文件托管 | 中间件短路 | 一行注册,框架接管 |
请求如何流转
读路径(查询):
客户端 → Controller → Queries → DbContext 视图 → MariaDB
写路径(命令):
客户端 → Controller → Mediator → TransactionHandler → CommandHandler → DbContext → MariaDB读写分离的核心收益:读不需要事务,直接走 Queries 最快;写通过 Mediator 统一管理事务和横切逻辑,业务代码不写 begin/commit。
建议的项目结构
下面是你自己开新项目时推荐采用的目录结构。这个结构已经在博客项目中验证过:
your-project/
├── web-api/ # 后端:仓颉 + Spire
│ ├── src/
│ │ ├── main.cj # 入口:WebHost 配置、中间件管道
│ │ ├── controllers/ # 控制器:路由入口,参数绑定
│ │ ├── application/
│ │ │ ├── commands/ # 命令(CQRS 写):Command + Handler
│ │ │ ├── queries/ # 查询(CQRS 读):Query 对象
│ │ │ ├── services/ # 业务服务:跨模块复用的逻辑
│ │ │ ├── models/ # 应用层模型:SearchModel 等
│ │ │ └── behaviors/ # 管道行为:事务、日志等
│ │ ├── domain/
│ │ │ ├── entities/ # 实体:数据库表映射(代码生成)
│ │ │ ├── models/ # DTO:序列化模型(代码生成)
│ │ │ └── views/ # 视图实体(代码生成)
│ │ ├── infrastructure/
│ │ │ ├── AdminDbContext.cj # 数据库上下文
│ │ │ ├── repositories/ # 仓储层
│ │ │ │ ├── BlogRepository.cj
│ │ │ │ ├── UserRepository.cj
│ │ │ │ └── CommentRepository.cj
│ │ │ ├── MapperUtilities.cj # 对象映射
│ │ │ ├── UserAccessor.cj # 当前用户访问器
│ │ │ └── middlewares/ # 中间件
│ │ └── abstractions/ # 抽象基类:Entity、QueryModel、PageResult
│ ├── template/ # 代码生成模板
│ ├── library/ # 本地依赖
│ └── wwwroot/ # 静态文件托管目录
└── blog.sql # 数据库初始化脚本目录结构的设计思路
从外到内看:
第一层:Controller(控制器) — 它只做三件事:接收请求参数、调用下层、返回结果。Controller 里不应该写任何 SQL 语句,也不应该写业务判断逻辑。
第二层:Application(应用层) — 业务逻辑的家。它分成三条独立路径:
| 路径 | 负责 | 什么时候用 |
|---|---|---|
| Queries(查询) | 读取数据 | 列表、详情、搜索,不修改数据 |
| Commands(命令) | 写入数据 | 创建、更新、删除,需要事务保护 |
| Services(服务) | 跨模块复用 | 一段逻辑被多个 Command 或 Controller 用到 |
第三层:Domain(领域层) — 纯粹的数据结构定义。Entity 对应数据库表,Model 是传给客户端的 DTO,View 对应数据库视图。这层不含任何业务逻辑。
第四层:Infrastructure(基础设施层) — 技术细节。数据库连接、仓储实现、当前用户获取、异常处理中间件都在这层。
为什么要加仓储层
注意上面结构中 infrastructure/repositories/ 这一层。你可能会问:DbContext 直接就能增删改查,为什么还要包一层 Repository?
一个简单的场景就能说明问题。假设你的 BlogService 里这样写:
// ❌ 直接在 Service 里写数据访问
public func getPublishedPosts(authorId: Int64): ArrayList<BlogPost> {
let param = DbParameters()
param.add("authorId", authorId)
return context.blogPosts
.params(param)
.whereIf("author_id = @authorId AND is_public = 1")
.toList()
}这段代码有两个问题:
- SQL 散落各处:
"author_id = @authorId AND is_public = 1"这个条件在BlogService、BlogQueries、UserService里各写一遍。字段名一变,你要改三个地方。 - 测试困难:要测
BlogService,你必须连真实数据库,因为AdminDbContext没法轻易替换。
用仓储层之后:
// ✅ Service 只关心业务,不关心 SQL
public func getPublishedPosts(authorId: Int64): ArrayList<BlogPost> {
return blogRepo.findPublishedByAuthor(authorId)
}具体怎么从"无仓储"演进到"有仓储",后面的 仓储层实战 会一步步讲解。
相关专题
如果你对某个基础概念不熟悉,建议先补基础再看本指南:
- 数据访问基础 → SqlSharp 总览
- 认证授权基础 → 身份认证总览
- 宿主与中间件 → Spire 基础设施总览
- 后台任务 → Scheduler 总览