@antprofuse/saddle-skill 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +21 -17
- package/agents/openai.yaml +3 -3
- package/assets/v1-business/Cargo.lock +1932 -0
- package/assets/v1-business/Cargo.toml +12 -0
- package/assets/v1-business/schema.sql +18 -0
- package/assets/v1-business/src/main.rs +42 -0
- package/assets/v1-business/src/order.rs +118 -0
- package/assets/v1-business/src/user.rs +54 -0
- package/package.json +5 -3
- package/references/db-0.1.1.md +96 -0
- package/references/gates-0.1.1.md +77 -0
- package/references/observability-0.1.1.md +69 -0
- package/references/overview-0.1.1.md +73 -0
- package/references/runtime-0.1.1.md +54 -0
- package/references/service-0.1.1.md +73 -112
- package/references/validation-0.1.1.md +50 -0
- package/scripts/check-business-boundaries.sh +5 -0
- package/scripts/check_business_boundaries.py +356 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CREATE TABLE users (
|
|
2
|
+
id BIGINT UNSIGNED NOT NULL PRIMARY KEY
|
|
3
|
+
);
|
|
4
|
+
|
|
5
|
+
CREATE TABLE orders (
|
|
6
|
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
7
|
+
user_id BIGINT UNSIGNED NOT NULL,
|
|
8
|
+
amount_cents BIGINT UNSIGNED NOT NULL,
|
|
9
|
+
CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id)
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TABLE order_audit (
|
|
13
|
+
order_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
14
|
+
event_name VARCHAR(64) NOT NULL,
|
|
15
|
+
CONSTRAINT order_audit_order_fk FOREIGN KEY (order_id) REFERENCES orders (id)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
INSERT INTO users (id) VALUES (1);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
mod order;
|
|
2
|
+
mod user;
|
|
3
|
+
|
|
4
|
+
use ::std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
|
5
|
+
|
|
6
|
+
use ::saddle::Saddle;
|
|
7
|
+
use ::saddle::{SaddleConfig, service::ServiceDescriptor};
|
|
8
|
+
use order::{CreateOrder, CreateOrderHandler};
|
|
9
|
+
use user::{GetUser, GetUserHandler};
|
|
10
|
+
|
|
11
|
+
fn main() -> ::saddle::Result<()> {
|
|
12
|
+
let database_url = ::std::env::var("SADDLE_DATABASE_URL")
|
|
13
|
+
.expect("SADDLE_DATABASE_URL must be set by deployment configuration");
|
|
14
|
+
let config = SaddleConfig::new(
|
|
15
|
+
"commerce",
|
|
16
|
+
database_url,
|
|
17
|
+
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
Saddle::run(config, |builder| {
|
|
21
|
+
let database = builder.database();
|
|
22
|
+
|
|
23
|
+
builder.register::<GetUser, _>(
|
|
24
|
+
ServiceDescriptor::new("user", "user", "get"),
|
|
25
|
+
GetUserHandler::new(database.clone()),
|
|
26
|
+
)?;
|
|
27
|
+
builder.expose_json::<GetUser>("/users/get");
|
|
28
|
+
|
|
29
|
+
builder.register_with::<CreateOrder, CreateOrderHandler, _>(
|
|
30
|
+
ServiceDescriptor::new("order", "order", "create"),
|
|
31
|
+
move |services| {
|
|
32
|
+
Ok(CreateOrderHandler::new(
|
|
33
|
+
database,
|
|
34
|
+
services.client::<GetUser>()?,
|
|
35
|
+
))
|
|
36
|
+
},
|
|
37
|
+
)?;
|
|
38
|
+
builder.expose_json::<CreateOrder>("/orders/create");
|
|
39
|
+
|
|
40
|
+
Ok(())
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
use ::saddle::{
|
|
2
|
+
CallContext, ErrorKind, SaddleError,
|
|
3
|
+
db::{Database, Statement},
|
|
4
|
+
obs::{self, DomainEvent, DomainEventError},
|
|
5
|
+
service::{Service, ServiceClient, ServiceFuture, ServiceHandler},
|
|
6
|
+
};
|
|
7
|
+
use ::serde::{Deserialize, Serialize};
|
|
8
|
+
|
|
9
|
+
use crate::user::{GetUser, GetUserRequest};
|
|
10
|
+
|
|
11
|
+
pub struct CreateOrder;
|
|
12
|
+
|
|
13
|
+
#[derive(Deserialize)]
|
|
14
|
+
pub struct CreateOrderRequest {
|
|
15
|
+
pub user_id: u64,
|
|
16
|
+
pub amount_cents: u64,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[derive(Serialize)]
|
|
20
|
+
pub struct CreateOrderResponse {
|
|
21
|
+
pub order_id: u64,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl Service for CreateOrder {
|
|
25
|
+
type Request = CreateOrderRequest;
|
|
26
|
+
type Response = CreateOrderResponse;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub struct CreateOrderHandler {
|
|
30
|
+
database: Database,
|
|
31
|
+
users: ServiceClient<GetUser>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
impl CreateOrderHandler {
|
|
35
|
+
pub fn new(database: Database, users: ServiceClient<GetUser>) -> Self {
|
|
36
|
+
Self { database, users }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl ServiceHandler<CreateOrder> for CreateOrderHandler {
|
|
41
|
+
fn call<'a>(
|
|
42
|
+
&'a self,
|
|
43
|
+
context: &'a CallContext,
|
|
44
|
+
request: CreateOrderRequest,
|
|
45
|
+
) -> ServiceFuture<'a, CreateOrderResponse> {
|
|
46
|
+
Box::pin(async move {
|
|
47
|
+
if request.amount_cents == 0 {
|
|
48
|
+
return Err(SaddleError::new(
|
|
49
|
+
ErrorKind::InvalidArgument,
|
|
50
|
+
"ORDER_AMOUNT_INVALID",
|
|
51
|
+
"order amount must be greater than zero",
|
|
52
|
+
));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let user = self
|
|
56
|
+
.users
|
|
57
|
+
.call(
|
|
58
|
+
context,
|
|
59
|
+
GetUserRequest {
|
|
60
|
+
user_id: request.user_id,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
.await?;
|
|
64
|
+
if !user.exists {
|
|
65
|
+
return Err(SaddleError::new(
|
|
66
|
+
ErrorKind::Business,
|
|
67
|
+
"ORDER_USER_NOT_FOUND",
|
|
68
|
+
"the order user does not exist",
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let user_id = request.user_id;
|
|
73
|
+
let amount_cents = request.amount_cents;
|
|
74
|
+
let order_id = self
|
|
75
|
+
.database
|
|
76
|
+
.transaction(context, "orders.create", move |transaction| {
|
|
77
|
+
Box::pin(async move {
|
|
78
|
+
let statement = Statement::new(
|
|
79
|
+
"orders.insert",
|
|
80
|
+
"INSERT INTO orders (user_id, amount_cents) VALUES (?, ?)",
|
|
81
|
+
)?
|
|
82
|
+
.bind(user_id)?
|
|
83
|
+
.bind(amount_cents)?;
|
|
84
|
+
let order_id = transaction.write(statement).await?.last_insert_id();
|
|
85
|
+
|
|
86
|
+
let audit = Statement::new(
|
|
87
|
+
"order_audit.insert",
|
|
88
|
+
"INSERT INTO order_audit (order_id, event_name) VALUES (?, ?)",
|
|
89
|
+
)?
|
|
90
|
+
.bind(order_id)?
|
|
91
|
+
.bind("order.created")?;
|
|
92
|
+
transaction.write(audit).await?;
|
|
93
|
+
|
|
94
|
+
Ok(order_id)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
.await?;
|
|
98
|
+
|
|
99
|
+
let event = DomainEvent::new("order.created")
|
|
100
|
+
.map_err(domain_event_error)?
|
|
101
|
+
.field("order_id", order_id)
|
|
102
|
+
.map_err(domain_event_error)?
|
|
103
|
+
.field("user_id", user_id)
|
|
104
|
+
.map_err(domain_event_error)?;
|
|
105
|
+
obs::record(context, event)?;
|
|
106
|
+
|
|
107
|
+
Ok(CreateOrderResponse { order_id })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fn domain_event_error(error: DomainEventError) -> SaddleError {
|
|
113
|
+
SaddleError::new(
|
|
114
|
+
ErrorKind::Internal,
|
|
115
|
+
"DOMAIN_EVENT_INVALID",
|
|
116
|
+
error.to_string(),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
use ::saddle::{
|
|
2
|
+
CallContext,
|
|
3
|
+
db::{Database, Statement},
|
|
4
|
+
service::{Service, ServiceFuture, ServiceHandler},
|
|
5
|
+
};
|
|
6
|
+
use ::serde::{Deserialize, Serialize};
|
|
7
|
+
|
|
8
|
+
pub struct GetUser;
|
|
9
|
+
|
|
10
|
+
#[derive(Deserialize)]
|
|
11
|
+
pub struct GetUserRequest {
|
|
12
|
+
pub user_id: u64,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#[derive(Serialize)]
|
|
16
|
+
pub struct GetUserResponse {
|
|
17
|
+
pub exists: bool,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
impl Service for GetUser {
|
|
21
|
+
type Request = GetUserRequest;
|
|
22
|
+
type Response = GetUserResponse;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub struct GetUserHandler {
|
|
26
|
+
database: Database,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl GetUserHandler {
|
|
30
|
+
pub fn new(database: Database) -> Self {
|
|
31
|
+
Self { database }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl ServiceHandler<GetUser> for GetUserHandler {
|
|
36
|
+
fn call<'a>(
|
|
37
|
+
&'a self,
|
|
38
|
+
context: &'a CallContext,
|
|
39
|
+
request: GetUserRequest,
|
|
40
|
+
) -> ServiceFuture<'a, GetUserResponse> {
|
|
41
|
+
Box::pin(async move {
|
|
42
|
+
let statement = Statement::new(
|
|
43
|
+
"users.find_by_id",
|
|
44
|
+
"SELECT id FROM users WHERE id = ? LIMIT 1",
|
|
45
|
+
)?
|
|
46
|
+
.bind(request.user_id)?;
|
|
47
|
+
let user = self.database.query_optional(context, statement).await?;
|
|
48
|
+
|
|
49
|
+
Ok(GetUserResponse {
|
|
50
|
+
exists: user.is_some(),
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antprofuse/saddle-skill",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "面向 AI Coding 的 Saddle 0.1.1
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "面向 AI Coding 的 Saddle V1 中文研发契约,基于 saddle-framework 0.1.1。",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"files": [
|
|
7
7
|
"SKILL.md",
|
|
8
8
|
"LICENSE-APACHE",
|
|
9
9
|
"LICENSE-MIT",
|
|
10
10
|
"agents",
|
|
11
|
-
"
|
|
11
|
+
"assets",
|
|
12
|
+
"references",
|
|
13
|
+
"scripts"
|
|
12
14
|
],
|
|
13
15
|
"publishConfig": {
|
|
14
16
|
"access": "public",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# DB 与事务
|
|
2
|
+
|
|
3
|
+
## 获取能力
|
|
4
|
+
|
|
5
|
+
数据库连接池由 Saddle 创建。业务只能从 `SaddleBuilder::database()` 获取可克隆的
|
|
6
|
+
`Database` 句柄,并把它注入 Handler:
|
|
7
|
+
|
|
8
|
+
```rust
|
|
9
|
+
let database = builder.database();
|
|
10
|
+
let handler = GetUserHandler::new(database.clone());
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
不存在业务可用的 `Database::connect`、pool 构造、关闭或底层 sqlx transaction API。
|
|
14
|
+
|
|
15
|
+
## 参数化语句
|
|
16
|
+
|
|
17
|
+
始终用 `Statement` 和 `bind` 传参:
|
|
18
|
+
|
|
19
|
+
```rust
|
|
20
|
+
let statement = Statement::new(
|
|
21
|
+
"users.find_by_id",
|
|
22
|
+
"SELECT id FROM users WHERE id = ? LIMIT 1",
|
|
23
|
+
)?
|
|
24
|
+
.bind(user_id)?;
|
|
25
|
+
|
|
26
|
+
let row = database.query_optional(context, statement).await?;
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- operation 必须是稳定、低基数名称,例如 `users.find_by_id`,不能包含 SQL 或业务 ID。
|
|
30
|
+
- 不拼接 SQL 参数。
|
|
31
|
+
- 可绑定 `Null`、`bool`、`i64`、`u64`、`f64`、字符串和字节。
|
|
32
|
+
- 使用 `query_optional` 查询零或一行,`query_all` 查询多行,`write` 执行写入。
|
|
33
|
+
- 使用 `DbRow` 的强类型读取方法;可空列使用对应 `optional_*` 方法。
|
|
34
|
+
- `WriteResult` 提供 `rows_affected()` 和 `last_insert_id()`。
|
|
35
|
+
|
|
36
|
+
## 单层事务
|
|
37
|
+
|
|
38
|
+
```rust
|
|
39
|
+
let order_id = database
|
|
40
|
+
.transaction(context, "orders.create", move |transaction| {
|
|
41
|
+
Box::pin(async move {
|
|
42
|
+
let order = Statement::new(
|
|
43
|
+
"orders.insert",
|
|
44
|
+
"INSERT INTO orders (user_id, amount_cents) VALUES (?, ?)",
|
|
45
|
+
)?
|
|
46
|
+
.bind(user_id)?
|
|
47
|
+
.bind(amount_cents)?;
|
|
48
|
+
let order_id = transaction.write(order).await?.last_insert_id();
|
|
49
|
+
|
|
50
|
+
let audit = Statement::new(
|
|
51
|
+
"order_audit.insert",
|
|
52
|
+
"INSERT INTO order_audit (order_id, event_name) VALUES (?, ?)",
|
|
53
|
+
)?
|
|
54
|
+
.bind(order_id)?
|
|
55
|
+
.bind("order.created")?;
|
|
56
|
+
transaction.write(audit).await?;
|
|
57
|
+
|
|
58
|
+
Ok(order_id)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
.await?;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- 闭包返回 `Ok` 时由 Saddle 提交,返回 `Err` 时由 Saddle 回滚。
|
|
65
|
+
- 事务内只用传入的 `&mut Transaction` 查询和写入。
|
|
66
|
+
- `Transaction` 没有 begin、commit、rollback、嵌套事务或 savepoint API。
|
|
67
|
+
- 事务闭包的 future 与 `Transaction` 借用绑定,不能捕获外层 `&CallContext`。
|
|
68
|
+
- 需要记录领域事件时,先让事务返回业务结果,再在提交成功后使用原上下文记录。
|
|
69
|
+
- 随包示例用两条关联写入验证原子性:第二条失败时第一条不会留在 `orders`。
|
|
70
|
+
|
|
71
|
+
## 资源边界
|
|
72
|
+
|
|
73
|
+
0.1.1 发布 API 固定了以下上限:SQL 65,536 字节、参数 256 个、单参数和单字段
|
|
74
|
+
各 1 MiB、参数总计 2 MiB、查询 10,000 行、结果总计 8 MiB。不要通过分片拼装、
|
|
75
|
+
循环小查询或其他方式规避这些限制;应缩小业务查询。
|
|
76
|
+
|
|
77
|
+
MariaDB 还需确保服务端 `max_allowed_packet` 不高于 8 MiB;0.1.1 会在启动时拒绝
|
|
78
|
+
更宽松的入站配置,返回 `db.invalid_config`。
|
|
79
|
+
|
|
80
|
+
## 错误模式
|
|
81
|
+
|
|
82
|
+
```rust
|
|
83
|
+
// 错误:字符串拼接参数。
|
|
84
|
+
Statement::new("users.find", format!("SELECT * FROM users WHERE id = {user_id}"))?;
|
|
85
|
+
|
|
86
|
+
// 错误:业务创建连接池或底层事务。
|
|
87
|
+
sqlx::MySqlPool::connect(url).await?;
|
|
88
|
+
|
|
89
|
+
// 错误:事务闭包捕获外层上下文,0.1.1 生命周期不允许。
|
|
90
|
+
database.transaction(context, "orders.create", |transaction| {
|
|
91
|
+
Box::pin(async move {
|
|
92
|
+
obs::record(context, event)?;
|
|
93
|
+
Ok(())
|
|
94
|
+
})
|
|
95
|
+
}).await?;
|
|
96
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# 业务边界门禁
|
|
2
|
+
|
|
3
|
+
执行随 Skill 发布的门禁:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
scripts/check-business-boundaries.sh path/to/Cargo.toml
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
交付前同时检查 Cargo 依赖和业务源码。扫描命中后先判断是否位于注释、文档或测试
|
|
10
|
+
反例中;生产业务代码命中则必须移除或交由控制 workspace 审核。
|
|
11
|
+
|
|
12
|
+
## 依赖
|
|
13
|
+
|
|
14
|
+
业务 crate 必须直接依赖:
|
|
15
|
+
|
|
16
|
+
```toml
|
|
17
|
+
saddle-framework = "=0.1.1"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
禁止直接依赖以下基础设施类别:
|
|
21
|
+
|
|
22
|
+
- `tokio`、`async-std` 等 runtime/task
|
|
23
|
+
- `axum`、`hyper`、`actix-web` 等 Web Server
|
|
24
|
+
- `sqlx`、数据库驱动或连接池
|
|
25
|
+
- `tracing-subscriber`
|
|
26
|
+
- HTTP/RPC、文件、进程和消息客户端
|
|
27
|
+
|
|
28
|
+
0.1.1 门禁采用封闭 allowlist:除精确 Saddle 门面外,仅批准
|
|
29
|
+
`serde = { version = "1", features = ["derive"] }`。需要新增纯计算依赖时,先由
|
|
30
|
+
控制 workspace 审核并更新门禁,不能在业务清单中自行加入。依赖别名、target
|
|
31
|
+
dependency、build/dev dependency、path/git/registry 来源和自定义 Cargo target
|
|
32
|
+
一律拒绝。
|
|
33
|
+
|
|
34
|
+
## 源码
|
|
35
|
+
|
|
36
|
+
业务生产代码不得出现:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
std::thread
|
|
40
|
+
tokio::runtime
|
|
41
|
+
tokio::spawn
|
|
42
|
+
spawn_blocking
|
|
43
|
+
axum::Router
|
|
44
|
+
hyper::Server
|
|
45
|
+
sqlx::Pool
|
|
46
|
+
sqlx::Transaction
|
|
47
|
+
tracing_subscriber
|
|
48
|
+
reqwest
|
|
49
|
+
std::fs
|
|
50
|
+
std::process
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
门禁要求外部 crate 使用不可被本地模块遮蔽的绝对路径,例如
|
|
54
|
+
`use ::saddle::Saddle;`。它拒绝 import alias、`include!`/`#[path]`、unsafe/FFI/汇编、
|
|
55
|
+
未批准的绝对 crate 路径和额外编译输入。业务不得定义宏,所有 `name!(...)` bang
|
|
56
|
+
宏调用均拒绝;属性只允许来自已批准 serde 的 Deserialize/Serialize derive。这样
|
|
57
|
+
能力判定不依赖猜测宏展开结果。标准库只批准示例使用的地址类型与部署环境变量读取。
|
|
58
|
+
随后执行 `cargo check --locked`,确认锁定依赖和真实入口可编译。
|
|
59
|
+
|
|
60
|
+
默认 `src/main.rs` 必须有且只有一个真实 `fn main`,并由该函数体调用从
|
|
61
|
+
`::saddle::Saddle` 导入的 `Saddle::run`;不接受字符串或本地同名类型伪造、
|
|
62
|
+
自定义入口、额外 binary/library 或 build script。整个源码树中的 `Saddle` 标识
|
|
63
|
+
只能出现于该绝对导入与唯一调用;main/块作用域禁止 `use`,`Saddle::run` 必须是
|
|
64
|
+
main 的尾部启动表达式,避免跨模块或局部名称遮蔽。
|
|
65
|
+
|
|
66
|
+
还要人工确认:
|
|
67
|
+
|
|
68
|
+
- 只有一个 `main`,且入口调用 `Saddle::run`。
|
|
69
|
+
- 所有外部入口都对应已注册的 Service。
|
|
70
|
+
- 内部模块调用使用 `ServiceClient`。
|
|
71
|
+
- DB 句柄来自 `SaddleBuilder::database()`。
|
|
72
|
+
- 所有 SQL 使用 `Statement` 参数绑定。
|
|
73
|
+
- 所有事务只通过 `Database::transaction`。
|
|
74
|
+
- 所有领域事件有界且不包含敏感 payload。
|
|
75
|
+
|
|
76
|
+
不要用别名、间接封装或字符串拆分绕过门禁。门禁发现确实需要的新通用能力时,
|
|
77
|
+
提交 Saddle 公共 API 缺口,不要在业务项目中放行旁路。
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# 错误、日志与 Trace
|
|
2
|
+
|
|
3
|
+
## 自动链路
|
|
4
|
+
|
|
5
|
+
Saddle 为外部入口创建或继承 `CallContext`,内部 Service 和 DB 调用接收父上下文。
|
|
6
|
+
业务只需把 Handler 收到的 `context` 原样传入 Saddle API,不要初始化 subscriber、
|
|
7
|
+
创建 span 或手工传播 Trace。
|
|
8
|
+
|
|
9
|
+
框架日志与 Trace 属于自动能力。业务不要重复记录入口、出口、耗时、DB 成功失败或
|
|
10
|
+
事务提交回滚等流程日志,只记录真正有领域含义的事件。
|
|
11
|
+
|
|
12
|
+
## 领域事件
|
|
13
|
+
|
|
14
|
+
```rust
|
|
15
|
+
let event = DomainEvent::new("order.created")
|
|
16
|
+
.map_err(domain_event_error)?
|
|
17
|
+
.field("order_id", order_id)
|
|
18
|
+
.map_err(domain_event_error)?
|
|
19
|
+
.field("user_id", user_id)
|
|
20
|
+
.map_err(domain_event_error)?;
|
|
21
|
+
obs::record(context, event)?;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- 事件名和字段名使用稳定、低基数标识。
|
|
25
|
+
- 字段只支持字符串、有符号整数、无符号整数和布尔值。
|
|
26
|
+
- 默认级别是框架定义值;需要时用 `level(EventLevel::...)` 设置。
|
|
27
|
+
- 不写完整请求、响应、SQL、SQL 参数、凭据、令牌或任意对象调试文本。
|
|
28
|
+
- `DomainEventError` 与 `SaddleError` 是不同错误;显式转换为不泄露敏感信息的
|
|
29
|
+
稳定框架错误。
|
|
30
|
+
- 事务成功事件在 `Database::transaction(...).await?` 返回后记录,避免把回滚操作
|
|
31
|
+
误记为成功,也避免捕获事务闭包不允许的外层上下文引用。
|
|
32
|
+
|
|
33
|
+
```rust
|
|
34
|
+
fn domain_event_error(error: DomainEventError) -> SaddleError {
|
|
35
|
+
SaddleError::new(
|
|
36
|
+
ErrorKind::Internal,
|
|
37
|
+
"DOMAIN_EVENT_INVALID",
|
|
38
|
+
error.to_string(),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 错误语义
|
|
44
|
+
|
|
45
|
+
- 输入格式或范围错误:`InvalidArgument`
|
|
46
|
+
- 资源不存在:`NotFound`
|
|
47
|
+
- 状态冲突:`Conflict`
|
|
48
|
+
- 预期业务拒绝:`Business`
|
|
49
|
+
- 临时不可用:`Unavailable`
|
|
50
|
+
- 基础设施失败:由 Saddle DB/Service 能力转换;业务不要泄露底层错误
|
|
51
|
+
- 不应发生的契约错误:`Internal`
|
|
52
|
+
|
|
53
|
+
随包示例在 0.1.1 上的可执行验收基线为:`InvalidArgument` 返回 HTTP 400,
|
|
54
|
+
`Business` 返回 HTTP 422,DB 基础设施错误返回 HTTP 500;错误体只暴露稳定 code
|
|
55
|
+
和 trace ID。升级 Saddle 制品后重新验证映射,不自行实现 HTTP 错误转换。
|
|
56
|
+
|
|
57
|
+
## 错误模式
|
|
58
|
+
|
|
59
|
+
```rust
|
|
60
|
+
// 错误:自行初始化框架日志。
|
|
61
|
+
tracing_subscriber::fmt().init();
|
|
62
|
+
|
|
63
|
+
// 错误:高基数事件名和敏感 payload。
|
|
64
|
+
DomainEvent::new(format!("order.created.{order_id}"))?
|
|
65
|
+
.field("request", format!("{request:?}"))?;
|
|
66
|
+
|
|
67
|
+
// 错误:伪造或替换 Trace 上下文。
|
|
68
|
+
let context = CallContext::new(...);
|
|
69
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Saddle V1 总览
|
|
2
|
+
|
|
3
|
+
## 应用模型
|
|
4
|
+
|
|
5
|
+
- 一个 Cargo 大库就是一个应用、一个构建单元和一个部署进程。
|
|
6
|
+
- 用户、订单等是同一大库中的业务模块,不是独立部署服务。
|
|
7
|
+
- 业务只定义 Service 契约、Handler、业务规则、内部调用和数据访问意图。
|
|
8
|
+
- Saddle 统一拥有进程入口、async runtime、HTTP/JSON 入口、数据库连接池、
|
|
9
|
+
事务边界、调用上下文、结构化日志和 Trace。
|
|
10
|
+
|
|
11
|
+
推荐目录:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
src/
|
|
15
|
+
├── main.rs # 唯一启动与装配入口
|
|
16
|
+
├── user.rs # 用户契约与 Handler
|
|
17
|
+
└── order.rs # 订单契约与 Handler
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 依赖
|
|
21
|
+
|
|
22
|
+
```toml
|
|
23
|
+
[package]
|
|
24
|
+
edition = "2024"
|
|
25
|
+
rust-version = "1.85"
|
|
26
|
+
|
|
27
|
+
[dependencies]
|
|
28
|
+
saddle-framework = "=0.1.1"
|
|
29
|
+
serde = { version = "1", features = ["derive"] }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Cargo 包名是 `saddle-framework`,Rust 库名是 `saddle`。0.1.x 尚无兼容承诺,
|
|
33
|
+
必须精确固定版本。
|
|
34
|
+
|
|
35
|
+
## 固定装配
|
|
36
|
+
|
|
37
|
+
```rust
|
|
38
|
+
let database_url = std::env::var("SADDLE_DATABASE_URL")
|
|
39
|
+
.expect("SADDLE_DATABASE_URL must be set by deployment configuration");
|
|
40
|
+
let config = SaddleConfig::new("commerce", database_url, listen);
|
|
41
|
+
|
|
42
|
+
Saddle::run(config, |builder| {
|
|
43
|
+
let database = builder.database();
|
|
44
|
+
// 注册内部和外部 Service。
|
|
45
|
+
Ok(())
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`SaddleConfig` 只表达应用标识、数据库地址和监听地址这些部署事实,不能用于选择
|
|
50
|
+
另一套 runtime、传输、数据库驱动或日志实现。
|
|
51
|
+
|
|
52
|
+
## 已验证闭环
|
|
53
|
+
|
|
54
|
+
`assets/v1-business` 基于 crates.io 0.1.1 编译,覆盖:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
HTTP/JSON 创建订单
|
|
58
|
+
-> CreateOrder Service
|
|
59
|
+
-> ServiceClient<GetUser>
|
|
60
|
+
-> Database::query_optional
|
|
61
|
+
-> Database::transaction
|
|
62
|
+
-> Transaction::write
|
|
63
|
+
-> 提交后记录 order.created 领域事件
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
示例只证明公共 API、依赖闭合和 Rust 生命周期成立。实际运行仍要求可访问的
|
|
67
|
+
MySQL/MariaDB 数据源和可绑定的监听地址。
|
|
68
|
+
|
|
69
|
+
## 0.1.1 非能力
|
|
70
|
+
|
|
71
|
+
不要生成定时任务、后台常驻任务、消息消费、外部 HTTP/RPC 客户端、文件 I/O、
|
|
72
|
+
多数据库抽象、嵌套事务、savepoint、分布式事务、通用 `spawn`、Metrics、
|
|
73
|
+
跨进程 Service 调用、服务发现、限流、重试或熔断代码。
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Runtime 与进程生命周期
|
|
2
|
+
|
|
3
|
+
## 正确模型
|
|
4
|
+
|
|
5
|
+
- 只定义一个同步 `main`,返回 `saddle::Result<()>`。
|
|
6
|
+
- 只通过 `Saddle::run` 创建并运行应用。
|
|
7
|
+
- 在 `Saddle::run` 的配置闭包中完成 Service 注册和入口暴露。
|
|
8
|
+
- Handler 通过 `ServiceFuture` 返回 async 业务执行。
|
|
9
|
+
- 将进程启动、监听、信号处理和关闭收敛交给 Saddle。
|
|
10
|
+
|
|
11
|
+
```rust
|
|
12
|
+
fn main() -> saddle::Result<()> {
|
|
13
|
+
let config = SaddleConfig::new(application, database_url, listen);
|
|
14
|
+
Saddle::run(config, |builder| {
|
|
15
|
+
// 只装配业务 Service。
|
|
16
|
+
Ok(())
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
0.1.1 没有业务可调用的 runtime、thread 或通用 task 创建 API。不要为了并行或
|
|
22
|
+
后台执行而增加 Tokio 直接依赖;当前 V1 业务执行必须留在 Service 调用链中。
|
|
23
|
+
|
|
24
|
+
## 上下文
|
|
25
|
+
|
|
26
|
+
每个 Handler 接收框架创建的 `&CallContext`。将同一个引用传给:
|
|
27
|
+
|
|
28
|
+
- `ServiceClient::call`
|
|
29
|
+
- `Database::query_all`、`query_optional`、`write`、`transaction`
|
|
30
|
+
- `obs::record`
|
|
31
|
+
|
|
32
|
+
不要手工创建或替换外部请求的上下文,也不要自行传播 Trace ID。业务可以读取
|
|
33
|
+
application、module、service、operation、trace_id 和 span_id,用于必要的业务判断
|
|
34
|
+
或低敏领域字段,但不要将完整上下文序列化到响应或日志。
|
|
35
|
+
|
|
36
|
+
## 错误模式
|
|
37
|
+
|
|
38
|
+
```rust
|
|
39
|
+
// 错误:业务拥有 runtime。
|
|
40
|
+
#[tokio::main]
|
|
41
|
+
async fn main() {}
|
|
42
|
+
|
|
43
|
+
// 错误:脱离 Saddle 请求生命周期。
|
|
44
|
+
tokio::spawn(async move { /* ... */ });
|
|
45
|
+
|
|
46
|
+
// 错误:真实线程和阻塞等待。
|
|
47
|
+
std::thread::spawn(|| {});
|
|
48
|
+
std::thread::sleep(duration);
|
|
49
|
+
|
|
50
|
+
// 错误:业务自行提供 HTTP 服务。
|
|
51
|
+
axum::Router::new();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
业务需要 V1 未提供的后台或并发执行模型时,提交公共能力需求,不要建立旁路。
|