一个简单的业务
下面这个是简化过的交易平台的模型,用户可以对账号进行充值、交易、提取等(比如国内的余支付宝、国外的 Paypal 等)。用户每增加一次交易,都会改变账户中的余额。
Account
是聚合根,控制对其关联Transaction
的所有操作- 任何对
Transaction
的修改必须通过Account
完成 Account
需保证交易金额的合法性(如余额不足时禁止提现)
'用户'
class Customer #green {
+id:number
}
'账号'
class Account #green {
+id:string
+amount:Amount
}
'交易'
class Transacion #pink {
+id:string
+amount:string
}
Customer "1" *-- "0..*" Account:owns
Account "1" *--> "0..*" Transacion:has
AI 生成的代码合理吗?
现在我们向 ai 提出“用 nest + typeorm 实现根据用户获取账户列表的需求”,AI 默认会给我们下面这样的结果。
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Account, account => account.user)
accounts: Account[];
}
@Entity()
export class Account {
@PrimaryGeneratedColumn()
id: number;
@Column()
accountNumber: string;
@ManyToOne(() => User, user => user.accounts)
user: User;
}
@Injectable()
export class AccountService {
constructor(
@InjectRepository(Account)
private accountRepository: Repository<Account>,
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async getAccountsByUserId(userId: number): Promise<Account[]> {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['accounts'],
});
return user ? user.accounts : [];
}
}
这种编码模式是一种很明显的贫血模型,它将数据和操作分离,导致大量的业务逻辑分散在多个服务或者工具类中。不仅给我们带来各种难以维护的“上帝类”,同时分散的业务也让我们每次代码变更都是“霰弹式修改”。
另一方面,上面的分层,下层的”模型层”决定了上层“服务层”的具体实现方式,是一种无效的分层。
现在我们让 ai “改造成充血模型实现,并且不要让模型层影响服务层”,ai 给出的回答如下:
1. 定义领域模型
模型层只包含领域逻辑,不依赖 TypeORM 的装饰器。
export class User {
constructor(
public id: number,
public name: string,
public accounts: Account[] = [],
) {}
// 领域逻辑:获取用户的账户列表
getAccounts(): Account[] {
return this.accounts;
}
// 领域逻辑:添加账户
addAccount(account: Account): void {
this.accounts.push(account);
}
}
export class Account {
constructor(
public id: number,
public accountNumber: string,
public user: User,
) {}
// 领域逻辑:设置账户的用户
setUser(user: User): void {
this.user = user;
}
}
2. 定义仓储接口
仓储接口是模型层与持久化层之间的抽象,Service 层依赖仓储接口。
export interface IUserRepository {
findById(id: number): Promise<User | null>;
save(user: User): Promise<void>;
}
export interface IAccountRepository {
save(account: Account): Promise<void>;
}
3. Service 层依赖抽象仓储接口
@Injectable()
export class AccountService {
constructor(
private readonly userRepository: IUserRepository,
private readonly accountRepository: IAccountRepository,
) {}
async getAccountsByUserId(userId: number): Promise<Account[]> {
const user = await this.userRepository.findById(userId);
return user ? user.getAccounts() : [];
}
async addAccountToUser(userId: number, account: Account): Promise<void> {
const user = await this.userRepository.findById(userId);
if (user) {
account.setUser(user);
user.addAccount(account);
await this.userRepository.save(user);
}
}
}
ai 对当前模式结果的评价是:
- 模型层独立:模型层仅包含领域逻辑,不依赖 TypeORM。
- Service 层无感知:Service 层依赖抽象的仓储接口,而不是具体的实现。
- 可替换性:如果需要更换持久化框架,只需实现新的仓储类,而不需要修改模型层或 Service 层。
的确有道理,但是仅限于当前这个简单的案例,当我们 User 实体关联了很多实体的时候,整个 User 实体会变得异常庞大。比如下面这样:
export class User {
constructor(
public id: number,
public name: string,
public accounts: Account[] = [],
public orders: Order[] = [],
public comments: Comment[] =
) {}
// 领域逻辑:获取用户的账户列表
getAccounts(): Account[] {
return this.accounts;
}
// 领域逻辑:添加账户
addAccount(account: Account): void {
this.accounts.push(account);
}
getOrders(): Order[] {
return this.orders;
}
createOrder(order: Order): void {
this.orders.push(order)
}
getComments(): Comment[] {
return this.comment;
}
addComment(comment: Comment): void {
this.comments.push(comment)
}
}
这就是我们平时遇到的所谓“使用 ai 提升效率”的最常见的场景,要么生成难以描述业务的贫血模型,加速代码的腐化,要么在及其有限的功能范围内,去实现扩展性并不那么强的代码。
我们之前讲到,软件开发的真正困难,在于如何处理业务复杂度,而 AI 恰恰无法理解现实业务。上面的案例,可以说最能体现“Ai is stupid”。我们不能指望一个只会东拼西凑的工具,来解决一个确定性的现实存在的问题。
上面所带来的更糟糕的现实是,没有多少人去解释 ai 生成代码的合理性,仅仅以是否能够让业务运行起来去衡量。ai 带来了表面上效率的提升,同样地,也比过去更加频繁带来了糟糕的代码质量,可以说这样的“程序员”,即不对自己负责,也不对自己所在的企业负责。
互联网:世间最大的系统
在互联网中,我们可以任意连接和获取各种资源。
充血模型:描述世间万物
充血模型是一种软件设计模式,特别是在领域驱动设计(DDD)中被广泛应用。它强调将业务逻辑和行为紧密地绑定到对应的实体(Entity)或值对象(Value Object)上,而不是将这些逻辑分散在 service 或 controller 中。通过这种方式,充血模型能够更好地反映现实世界中的业务规则和交互,使代码更具表达力和可维护性。
具体来说,充血模型要求在实体上直接定义与该实体相关的业务行为方法。例如,在一个图书借阅系统中,如果需要表达“读者借阅书籍”这一行为,应该在reader
实体上定义一个borrowBooks
方法,即 reader.borrowBooks(booId:number)
,在这个方法里触发对书籍订阅状态的更新,而不是 userRepository.update(...)
直接把对数据库的更新暴露出来。 这种设计方式使得实体不仅包含数据(属性),还包含与这些数据相关的行为(方法),从而形成一个“充血”的实体。
现在我们把上面例子中的的 Typeorm 注解的“User 实体”,改造成真正的实体。 4. 首先我们要去除的:是和持久化技术 Typeorm 相关的内容 5. 其次对于实体来说:我们考虑一个对象是不是实体,唯一的判断标准就是它是否有身份(identity) 6. 再来对于实体来说:我们考虑的是这个实体和其它实体之间的关联关系(association),这个后面会讲到。 7. 最后对于实体来说:才是这个实体中具体哪些有哪些值(value)
export type UserDescription = Readonly<{
name: string;
email: string;
}>;
// 这里的使用了 immutable 构造不可变对象
export class User implements Entity<number, UserDescription> {
private immutableDescription = fromJS(this.description);
constructor(private identity: number, private description: UserDescription) {}
getIdentity(): number {
return this.identity;
}
getDescription(): UserDescription {
return this.immutableDescription.toJS() as unknown as UserDescription;
}
}
谁来操作实体?
对于用户(user)和任务(category),user 与 category 是一对多的关系的情况下,如果我们要添加一个分类,那么自然而然的,我们会把添加分类的逻辑,绑定在 user 实体上,即user.addCategory(...)
。但是这样,我们却造成一个问题,我们不得不在领域层中,引入具体的技术实现。
export class User {
addCategory() {
db.executeCreate(...)
}
}
那么我们可不可以对 categories 集合设计一个单独的 repository 呢,比如:
export class CategoriesReository {
db.executeCreate(...)
}
这样也不行,这样就违背了我们使用面向对象构造充血模型的初衷,我们又遇到了把业务逻辑散落散落在各处的问题。
我们可以仔细回想下平时在表中查询数据的过程,比如这个 SQL SELECT * FROM users WHERE phone_number = '手机号'
;我们在查询某一个内容时,首先第一件事,就是要明确它在哪一个数据表(集合下),而我们的需求则是,希望把这个对数据库操作的具体实现隐藏起来。那么我们可以对这个集合进行建模!
比如 user 对 category 的操作,也可以说是在 category 集合下进行操作,但是 category 不可以独立于 user 存在,不然你这功能就没有意义(你用户都没有,你用个啥)。我们既要对 cateory 集合进行建模,又要体现出 user 和 category 之间的关联关系(user 是 category 的聚合根)。最简单的方式就是,把关联关系体现在接口命名上,即 UserCategories。
// 持久化的时候,不论用什么持久化框架和数据库都与我无关,只要实现单独实现接口就好了
export interface UserCategories {
addCategory(): Promise<Category>
findById(): Promise<Category>
}
而对于查询 user,由于它已经是模型的入口了,没有上游的聚合根前缀,我们只需要构建一个名为 Users 的接口即可。
export interface Users {
findByIdentity(id: number): Promise<User>;
}