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)。

回到 文档导航。