我们有没有 DAO 层 · znzryb
这篇文章想用一个小时,把这个网站的后端、前端、判题机是怎么搭起来的讲清楚。写法跟专题一样,一节讲一件事,每节标了大概要读多久:
| 节 | 内容 | 时间 |
|---|---|---|
| 1 | 先回答一个问题:我们有没有 DAO 层 | 8 分钟 |
| 2 | 一张总图:请求从浏览器走到数据库 | 8 分钟 |
| 3 | 跟着一个请求走一遍:题目列表 | 10 分钟 |
| 4 | 跟着一次 AC 走一遍:判题机和经验值 | 10 分钟 |
| 5 | 数据库:迁移和「内容的真身在库里」 | 8 分钟 |
| 6 | 前端:React + 一层很薄的 fetch | 6 分钟 |
| 7 | 一小时读码路线 | 10 分钟 |
文中提到的文件路径都是仓库里真实存在的,可以边读边打开对照。
## 经典四层长什么样
学 Java Web 的时候,书上画的通常是这样一摞:
```text
Controller 收请求、校验参数、返回 JSON
↓
Service 业务逻辑(「下单要扣库存、记流水」)
↓
DAO 数据访问:一个方法对应一条 SQL(MyBatis 的 Mapper 就是这一层)
↓
Table 数据库里的表
```
每一层只跟下一层说话。好处是分工清楚:写 Service 的人不用碰 SQL,换数据库只改 DAO。
## 我们的实际情况:没有 DAO 层
**你的判断是对的。** 这个项目的后端用 Go 写,数据库驱动是 `pgx`(PostgreSQL 的 Go 驱动),**SQL 直接写在用到它的地方**:
```go
// backend/internal/server/problems/problems.go 里的 List(题目列表)
rows, err := s.DB.Query(ctx,
`SELECT p.slug, p.title, ... FROM problems p LEFT JOIN accepted_records ar ...`, ...)
```
`s.DB` 就是连接池,`Query` / `QueryRow` / `Exec` 是 pgx 自带的方法。中间没有 MyBatis 那样的 Mapper,也没有 ORM(把表映射成对象的库),也没有 sqlc 这类「从 SQL 生成 Go 代码」的工具。整个后端非测试代码里,像这样直接调 `s.DB.Query / QueryRow / Exec / Begin` 的地方有三百多处。
## 但也不是「三层」:Service 层是零散的
那是不是「Controller + Service + 直接 SQL」三层?也不完全是。
- 大多数接口的**参数校验、写 SQL、拼返回值都在同一个函数里**做完,也就是说 Controller 和 Service 是合在一起的;
- 只有**几块逻辑比较重、或者好几个地方都要用**的,被抽成了单独的包,放在 `backend/internal/` 下面,比如:
| 包 | 管什么 |
|---|---|
| `gamify` | 经验值、连击、成就 |
| `content` | 专题 / 课文 / 题面的修订记录 |
| `judge` | 判题队列、调度、重判 |
| `prepare` | 出题工作台 |
这些包**里面也是直接写 SQL**,它们收的参数是一个事务(`pgx.Tx`)或者连接池,自己在里面 `tx.Exec(...)`。
所以一句话总结:
```text
经典四层: Controller → Service → DAO → Table
我们: handler(= Controller + 大部分 Service)
└─(重的逻辑)→ 领域包(gamify / content / judge …)
两者都直接写 SQL → Table
```
## 为什么敢这么写
不是偷懒,是有几个条件撑着:
1. **只用一种数据库**(PostgreSQL),不打算换,DAO 层「方便换库」的那个好处用不上;
2. **SQL 本身就是最好读的数据访问代码**:一个带 `LEFT JOIN LATERAL` 的查询,写成 SQL 一眼看懂,包成 ORM 调用反而难懂;
3. **事务边界很重要**:比如「AC 了」要同时写提交结果、AC 记录、经验值流水、连击、成就,必须在一个事务里要么全成要么全不成。直接把 `tx` 往下传,比在 DAO 层之间协调事务简单得多。
代价也要知道:同一张表的 SQL 散落在好几个文件里,改表结构时得全局搜一遍(`grep` 表名)。项目里唯一的一层小抽象是 `content.Querier` 接口(`backend/internal/content/store.go`),它只做一件事:让同一个函数既能传事务、也能传连接池进去。