13 · 教程:从零搭一个完整 CRUD 服务
本教程把前面各章串起来,从空目录搭一个可运行的 Notes API:分层结构、实体/仓储/服务/控制器、DTO 校验、事务、自定义错误、配置驱动。读完你会得到一个标准的 ACE 工程骨架。
前置:已能
source scripts/env.sh && cjpm build(见 02 · 快速开始)。本教程假设工程放在仓库examples/notes-api/(与其它 example 同级,方便复用 workspace 的 stdx 接入)。
0. 目标
一个对 Note(笔记)的 CRUD:
| 方法 | 路径 | 作用 |
|---|---|---|
POST |
/api/notes |
创建(请求体校验) |
GET |
/api/notes |
列表 |
GET |
/api/notes/:id |
取单条(不存在 → 404) |
PUT |
/api/notes/:id |
更新 |
DELETE |
/api/notes/:id |
删除 |
1. 工程骨架
目录结构(按领域分包,每类一文件):
examples/notes-api/
├── cjpm.toml
├── config/
│ └── application.toml
└── src/
├── main.cj
├── domain/note.cj
├── dto/note_dtos.cj
├── errors/note_not_found.cj
├── exception/note_filter.cj
├── service/note_service.cj
└── controller/note_controller.cj
cjpm.toml——依赖框架 + ORM(路径相对仓库内 module):
[package]
cjc-version = "1.0.0"
name = "notes_api"
output-type = "executable"
[dependencies]
"aceboot::framework" = { path = "../../ace-framework/ace-framework" }
"aceboot::framework_macros" = { path = "../../ace-framework/ace-framework-macros" }
"aceboot::framework_boot" = { path = "../../ace-framework/ace-framework-boot" }
"aceboot::orm" = { path = "../../ace-orm" }
# 本机 stdx 预编译接入(照搬其它 example 的 target 段,见 examples/task-api/cjpm.toml)
[target.aarch64-apple-darwin]
[target.aarch64-apple-darwin.bin-dependencies]
path-option = ["${CANGJIE_STDX_PATH}/static/stdx"]
新增成员后记得把
"examples/notes-api"加入根cjpm.toml的[workspace].members。ORM 用 SQLite 时还需链接-lsqlite3,照抄examples/task-api/cjpm.toml的link-option。
2. 实体(domain/note.cj)
package notes_api.domain
import aceboot::orm.*
import aceboot::orm.macros.*
@Entity["notes"]
public class Note {
@Id[]
public var id: Int64 = 0
@Column[nullable = false]
public var title: String = ""
@Column[]
public var content: String = ""
@Column[]
public var done: Bool = false
}
@Entity 宏会自动生成 NoteRepository 并在启动期登记仓储工厂;OrmComponent 会据此建表并把仓储注册为可注入 Bean。
3. DTO 与校验(dto/note_dtos.cj)
package notes_api.dto
import aceboot::framework.*
import aceboot::framework_macros.*
@Dto
public class CreateNoteRequest {
@NotEmpty @Size[1, 120]
public var title: String = ""
public var content: String = ""
}
@Dto
public class UpdateNoteRequest {
@NotEmpty @Size[1, 120]
public var title: String = ""
public var content: String = ""
public var done: Bool = false
}
@Dto
public class NoteView {
public var id: Int64 = 0
public var title: String = ""
public var content: String = ""
public var done: Bool = false
}
DTO 字段须有默认值;当前不支持数组字段,列表响应见下方控制器
list的手拼 JSON 方式。
4. 自定义错误(errors + exception filter)
errors/note_not_found.cj:
package notes_api.errors
import aceboot::framework.*
import aceboot::framework_macros.*
@Exception
public class NoteNotFoundError {} // 自动 <: Exception + 标准构造器
exception/note_filter.cj——把它映射为干净的 404:
package notes_api.exception
import aceboot::framework.*
import aceboot::framework_macros.*
import notes_api.errors.*
@Catch
public func onNoteNotFound(ctx: Context, e: NoteNotFoundError): Unit {
ctx.status = 404
ctx.json("{\"error\":\"note not found\",\"code\":\"NOTE_NOT_FOUND\"}")
}
5. 服务层(service/note_service.cj)
业务逻辑 + 事务。仓储经 @Inject 注入:
package notes_api.service
import aceboot::framework.*
import aceboot::framework_macros.*
import aceboot::orm.*
import notes_api.domain.*
import notes_api.dto.*
import notes_api.errors.*
@Service
public class NoteService {
@Inject
var repo: NoteRepository
public func create(req: CreateNoteRequest): NoteView {
let n = Note()
n.title = req.title
n.content = req.content
repo.insert(n) // 回填 n.id
toView(n)
}
public func listJson(): String {
// DTO 暂不支持数组字段,列表手动拼 JSON(StringBuilder,同 task-api 写法)
let sb = StringBuilder()
sb.append("[")
var first = true
for (n in repo.findAll()) {
if (!first) { sb.append(",") }
first = false
sb.append(toView(n).toJson())
}
sb.append("]")
sb.toString()
}
public func get(id: Int64): NoteView {
match (repo.findById(DbInt(id))) {
case Some(n) => toView(n)
case None => throw NoteNotFoundError("note ${id} not found")
}
}
@Transactional
public func update(id: Int64, req: UpdateNoteRequest): NoteView {
let n = match (repo.findById(DbInt(id))) {
case Some(x) => x
case None => throw NoteNotFoundError("note ${id} not found")
}
n.title = req.title
n.content = req.content
n.done = req.done
repo.update(n)
toView(n)
}
public func remove(id: Int64): Unit {
if (repo.findById(DbInt(id)).isNone()) {
throw NoteNotFoundError("note ${id} not found")
}
repo.deleteById(DbInt(id))
}
private func toView(n: Note): NoteView {
let v = NoteView()
v.id = n.id; v.title = n.title; v.content = n.content; v.done = n.done
v
}
}
StringBuilder需import std.collection.*(框架 import 通常已带入,缺则显式加)。
6. 控制器(controller/note_controller.cj)
把 HTTP 端点接到服务。返回 DTO 自动 JSON、返回 String 原样、抛 NoteNotFoundError 经过滤器变 404:
package notes_api.controller
import aceboot::framework.*
import aceboot::framework_macros.*
import notes_api.service.*
import notes_api.dto.*
@Controller["/api/notes"]
public class NoteController {
@Inject
var notes: NoteService
@Post["/"]
public func create(body: CreateNoteRequest): NoteView { // body 自动反序列化 + 校验(失败 400)
notes.create(body)
}
@Get["/"]
public func list(): String {
notes.listJson()
}
@Get["/:id"]
public func getOne(id: Int64): NoteView { // :id 自动转 Int64;不存在抛 404
notes.get(id)
}
@Put["/:id"]
public func update(id: Int64, body: UpdateNoteRequest): NoteView {
notes.update(id, body)
}
@Delete["/:id"]
public func remove(id: Int64): String {
notes.remove(id)
"{\"deleted\":${id}}"
}
}
7. 配置(config/application.toml)
ace.env = "local"
[server]
host = "127.0.0.1"
port = 8080
[datasource]
driver = "sqlite"
url = "notes.db"
[openapi]
enabled = true
title = "Notes API"
version = "1.0.0"
数据源用 SQLite 文件;切 PostgreSQL 只需新建 application-pg.toml 覆盖 [datasource],业务代码零改动(见 09 · 数据源)。
8. 入口(main.cj)
关键:import 所有含 @Controller/@Service/@Entity/@Catch 的包,自注册才生效:
package notes_api
import aceboot::framework.*
import aceboot::framework_macros.*
import aceboot::framework_boot.*
import aceboot::orm.*
// 触发各层自注册(缺 import 则不装配):
import notes_api.controller.*
import notes_api.service.*
import notes_api.domain.*
import notes_api.exception.*
main(args: Array<String>): Int64 {
AceApplication.runWithArgs(args) // 解析 --env,按配置启动;ORM 自动建表
return 0
}
9. 构建运行
source scripts/env.sh
cjpm build
cd examples/notes-api/config # cwd 需能找到 application.toml
/Volumes/coder/cangjie/cue/target/release/bin/notes_api
# 启动日志: ACE serving on http://127.0.0.1:8080 (env=local)
10. 验证
# 创建
curl -s -X POST http://127.0.0.1:8080/api/notes \
-H 'Content-Type: application/json' \
-d '{"title":"buy milk","content":"2L"}'
# {"id":1,"title":"buy milk","content":"2L","done":false}
# 校验失败 → 400
curl -s -X POST http://127.0.0.1:8080/api/notes -d '{"title":""}'
# {"error":"..."}
# 列表
curl -s http://127.0.0.1:8080/api/notes
# [{"id":1,"title":"buy milk","done":false}]
# 取单条
curl -s http://127.0.0.1:8080/api/notes/1
# 不存在 → 自定义 404
curl -s http://127.0.0.1:8080/api/notes/999
# {"error":"note not found","code":"NOTE_NOT_FOUND"}
# 更新 / 删除
curl -s -X PUT http://127.0.0.1:8080/api/notes/1 \
-d '{"title":"buy milk","content":"2L","done":true}'
curl -s -X DELETE http://127.0.0.1:8080/api/notes/1
# {"deleted":1}
# OpenAPI / Swagger(内置,openapi.enabled=true)
curl -s http://127.0.0.1:8080/openapi.json
# 浏览器打开 http://127.0.0.1:8080/docs
11. 下一步
在这个骨架上继续叠加(各章都有现成做法):
- 加认证:写
@Middleware接jwtAuth,登录端点签signJwt,业务里currentPrincipal(ctx)取用户作为Note.owner(08 · 安全)。 - 加缓存:热点查询方法上
@Cacheable[ttl](07 · AOP)。 - 加流式:导出大量笔记用
SseStream或二进制下载(06 · 请求与响应)。 - 封装可复用能力:把某个横切关注点做成
@Component,按配置自动装配(10 · 组件扩展)。 - HTTPS / 多环境:加
application-prod.toml配[server.tls],--env=prod启动(04 · HTTPS)。
回到 文档导航。