准备工作
开发环境:node12.x以上
开发语言:TypeScript、JavaScript
内置模块:
1)lerna (可选,用于管理各个模块的依赖关系、发布版本,一键发布推送到git仓库和npm仓库)
2)jest (必选,团队采用TDD模式开发,要求每个开发者都必须掌握一定的单元测试能力)
3)class-transformer (必选,数据转换工具,是项目中的常用工具)
4)class-validator (必选,数据验证工具,是项目中的常用工具)
5)mongoose+typegoose(可选,虽然框架底层封装了mongoose的操作,但有些业务场景需要开发者使用mongoose或typegoose更多的api来实现)
下载项目源代码
git clone https://github.com/feiyueXH/lzy-midway-ddd.git
组件开发
下载组件模板代码
cd packages
git clone https://github.com/feiyueXH/lzy-component-template.git
组件项目结构
│ .gitignore
│ jest.config.js ## jest工具配置文件
│ package-lock.json
│ package.json
│ tsconfig.json
├───src
│ │ configuration.ts ## 自启动文件
│ │ index.ts ## 入口文件
│ │ interface.ts
│ ├───application ## 应用层
│ │ ├───checker
│ │ │ │ interface.ts
│ │ │ └───impl
│ │ │ user.checker.ts
│ │ ├───command ## 命令
│ │ │ │ change-password.command.ts
│ │ │ │ register-user.command.ts
│ │ │ └───executor ## 命令执行者
│ │ │ user.ts
│ │ └───query ## 查询
│ │ │ list-user.query.ts
│ │ └───executor ## 查询执行者
│ │ user.ts
│ ├───config
│ │ config.default.ts ## 配置文件
│ ├───domain ## 领域层
│ │ ├───aggregate ## 聚合(包含聚合根、实体、聚合根)
│ │ │ user.ts
│ │ └───event ## 领域事件
│ │ user-registered.event.ts
│ ├───infrastructure ## 基础设施层
│ │ └───db
│ │ └───mongo
│ │ └───models ## 定义typegoose的Model
│ │ user.ts
│ └───web
│ └───api ## 对外接口
│ hello.controller.ts
│ user.controller.ts
└───test ## 测试代码
注意:组件项目中的.git文件夹需要自行删除,lerna项目下已经有.git文件夹,如果不删除会造成冲突
架构图
牛刀小试
功能需求:注册用户和查询用户列表
备注:注册用户表单内容:用户名、密码、邮箱
开发注册用户功能
因为注册用户功能是一个写操作,所以要走执行命令流程
开发流程为:命令—>命令执行者—>领域模型(聚合根、实体、值对象、领域事件)—>数据模型
命令(Command)
// src\application\command\register-user.command.ts
// ddd-cqrs是一个自行封装的核心模块,作用是为了提供ddd、cqrs架构下所需的基类、事件总线、命令总线、查询总线、事务处理、能够自动分拣持久化数据通用化仓储等等
import { SuperCommand } from '@lzy-plugin/ddd-cqrs';
import { Expose } from 'class-transformer';
export class RegisterUserCommand extends SuperCommand {
@Expose()
username: string;
@Expose()
password: string;
@Expose()
email: string;
}
命令执行者(Command Executor)
// src\application\command\executor\user.ts
import {
CommonRepository, // 通用仓储
SuperCommand,
SuperCommandExecutor,
RepositoryManager, // 仓储管理者
SubscribeCommand
} from '@lzy-plugin/ddd-cqrs';
import { Inject, Provide } from '@midwayjs/decorator';
import { User } from '../../../domain/aggregate/user';
import { IUserChecker } from '../../checker/interface';
import { RegisterUserCommand } from '../register-user.command';
import { ChangePasswordCommand } from '../change-password.command';
@SubscribeCommand([RegisterUserCommand, ChangePasswordCommand])// 订阅命令,命令总线会自动收集订阅
@Provide()
export class UserCommandExecutor extends SuperCommandExecutor {
@Inject()
userChecker: IUserChecker; // 用户校验器
userRepository: CommonRepository<User>; //用户仓储
public async init(repositoryManager: RepositoryManager): Promise<void> {
this.userRepository = await repositoryManager.get(User);// 从仓储管理者获取用户仓储
}
public async executeCommand<C extends SuperCommand>(
command: C
): Promise<void> {
if (command instanceof RegisterUserCommand) {
await this.registerUser(command);
} else if (command instanceof ChangePasswordCommand) {
await this.changePassword(command);
}else {
throw new Error('未实现业务逻辑');
}
}
/**
* 注册用户
* @param command
*/
private async registerUser(command: RegisterUserCommand): Promise<void> {
const user = await User.create(User, command, this.userChecker);// 创建一个用户
await this.userRepository.add(user);// 调用用户仓储添加用户,执行命令结束后,将自动持久化。
}
/**
* 修改密码
* @param command
*/
private async changePassword(command: ChangePasswordCommand): Promise<void> {
const user = await this.userRepository.get(command._id);
user.changePassword(command.password); //从仓储内部实现了数据变化侦测,所以改完属性值后,命令执行完毕,仓储会自动持久化。
}
}
领域模型(Domain Model)
// src\domain\aggregate\user.ts
import {
AggregateRoot,
Entity,
CommonException,
ExceptionCodeEnum
} from '@lzy-plugin/ddd-cqrs';
import { Expose } from 'class-transformer';
import { IsNotEmpty } from 'class-validator';
import { IUserChecker } from '../../application/checker/interface';
export class User extends AggregateRoot {//聚合根
@Expose()
@IsNotEmpty()
username: string;
@Expose()
@IsNotEmpty()
password: string;
@Expose()
@IsNotEmpty()
email: string;
changePassword(password: string): void {
this.password = password;
}
static async create<E extends Entity>(
clazz: new () => E,
object: Record<string, any>,
userChecker: IUserChecker
): Promise<E> {
if (await userChecker.isExistedByUsername(object)) {
throw new CommonException(
ExceptionCodeEnum.DB_FAIL_CREATE_UNIQUE,
'账号已被注册'
);
}
return await Entity.create(clazz, object);
}
}
数据模型 (Data Model)
// src\infrastructure\db\mongo\models\user.ts
// 自行封装的ORM模块
import { SuperModel, TypegooseModel } from '@lzy-plugin/mongo-context';
import { prop } from '@typegoose/typegoose';
import { Expose } from 'class-transformer';
// 声明为TypegoooseModel,modelName必须与领域层中的实体类名一致,仓储才可以根据实体名称调用ORM持久化
@TypegooseModel({ modelName: 'User', collectionName: 'users' })
export class UserModel extends SuperModel {
@Expose()
@prop()
username: string;
@Expose()
@prop()
password: string;
@Expose()
@prop()
email: string;
}
以上代码就已经完成了注册用户的业务开发,接下来就是对外开发一个接口提供调用
import { SuperController } from '@lzy-plugin/ddd-cqrs';
import { Controller, Post, Body, ALL, Provide, Get } from '@midwayjs/decorator';
import { plainToClass } from 'class-transformer';
import { RegisterUserCommand } from '../../application/command/register-user.command';
@Controller('/users')
@Provide()
export class UserController extends SuperController {
@Post('/')
async registerUser(@Body(ALL) body: any): Promise<void> {
//创建命令
const command: RegisterUserCommand = plainToClass(
RegisterUserCommand,
body
);
//通过命令总线发布命令,然后等待命令执行完毕
await this.commandBus.send(command, { dbKey: 'admin' });
//命令执行成功
this.httpHelper.success();
}
}
开发查询用户列表功能
这是一个读操作,并不会写入任何数据,所以走执行查询流程
开发流程:查询—>查询执行者—>调用ORM查询各种数据
查询 (Query)
import { IMatchRule, SuperQuery } from '@lzy-plugin/ddd-cqrs';
import { Expose } from 'class-transformer';
export class ListUserQuery extends SuperQuery {
@Expose()
username?: string | IMatchRule;
}
查询执行者 (Query Executor)
import {
getZoneData,
SubscribeQuery,
SuperQuery,
SuperQueryExecutor
} from '@lzy-plugin/ddd-cqrs';
import { IMongoManager, MongoContext } from '@lzy-plugin/mongo-context';
import { Provide } from '@midwayjs/decorator';
import { ListUserQuery } from '../list-user.query';
@SubscribeQuery([ListUserQuery])
@Provide()
export class UserQueryExecutor extends SuperQueryExecutor {
mongoContext: MongoContext;
async init(mongoManager: IMongoManager): Promise<any> {
this.mongoContext = await mongoManager.getContext(getZoneData('dbKey'));
}
async executeQuery<Q extends SuperQuery>(query: Q): Promise<any> {
if (query instanceof ListUserQuery) {
return await this.listQuery(query);
}
throw new Error('Method not implemented.');
}
async listQuery(query: ListUserQuery): Promise<any[]> {
const result = await this.mongoContext.switchModel('User').find(query);
return result;
}
}
接下来只需要对外开放接口即可。
import { SuperController } from '@lzy-plugin/ddd-cqrs';
import { Controller, Post, Body, ALL, Provide, Get } from '@midwayjs/decorator';
import { plainToClass } from 'class-transformer';
import { ListUserQuery } from '../../application/query/list-user.query';
@Controller('/users')
@Provide()
export class UserController extends SuperController {
@Get('/')
async listUser(@Body(ALL) body: any): Promise<void> {
const query: ListUserQuery = plainToClass(ListUserQuery, body);
const result = await this.queryBus.send(query, { dbKey: 'admin' });
this.httpHelper.success(result);
}
}
组件调试
组件开发完了,该如何进行调试?
方法有两种:
1)将组件的package.json中main入口改为src/index.ts,然后通过lerna添加依赖和建立软连接进行热更调试。但这个方法目前存在缺陷,midwayjs没办法监听node_modules中npm建立软连接生成的代码,所以无法热更,目前midwayjs已经修复,但还没发布出来,也不知道怎么使用监听更多文件,所以这个调试方案暂放
2)在每个组件编写测试代码,对组件进行单元测试。
目录结构
└─test
│ .setup.js
│ index.test.ts ## 测试代码文件(重点)
└─fixtures
└─base-app ## 模拟将组件部署到应用上
│ package.json
│ tsconfig.json
├─logs
├─run
└─src
│ configuration.ts ## 自定义启动文件(重点)
└─config
config.default.ts ## 配置文件(重点)
在模拟将组件部署到应用上之前,我们先导出组件中定义好的各个类
// packages\user\src\index.ts
export { AutoConfiguration as Configuration } from './configuration';
export * from './application/command/change-password.command';
export * from './application/command/executor/user';
export * from './application/command/register-user.command';
export * from './application/query/list-user.query';
export * from './application/query/executor/user';
export * from './domain/aggregate/user';
export * from './domain/event/user-registered.event';
export * from './infrastructure/db/mongo/models/user';
export * from './web/api/user.controller';
所有被装饰器装饰的类都必须导出,尤其是被Provide装饰的类,这样才能被框架扫描之后执行一些操作:比如添加到控制反转容器、实例化、收集依赖等,例如:
// src\infrastructure\db\mongo\models\user.ts
@TypegooseModel({ modelName: 'User', collectionName: 'users' })
export class UserModel extends SuperModel {}
// src\web\api\user.controller.ts
@Controller('/users')
@Provide()
export class UserController extends SuperController {}
// src\application\command\executor\user.ts
@SubscribeCommand([RegisterUserCommand, ChangePasswordCommand])
@Provide()
export class UserCommandExecutor extends SuperCommandExecutor {}
接下来就可以模拟部署到应用中了
// packages\user\test\fixtures\base-app\src\configuration.ts
import { App, Config, Configuration } from '@midwayjs/decorator';
//导入刚刚开发的用户组件
import * as developTemplate from '../../../../src';
import { join } from 'path';
import { ILifeCycle } from '@midwayjs/core';
import { Application } from 'egg';
import { IMongoConfig, MongoContext } from '@lzy-plugin/mongo-context';
@Configuration({
imports: [developTemplate],//添加用户组件
importConfigs: [join(__dirname, 'config')]
})
export class ContainerLifeCycle implements ILifeCycle {
@App()
app: Application;
@Config('mongoConfig')
mongoConfig: IMongoConfig;
async onReady(): Promise<void> {
// 传入mongodb连接参数,连接数据库
await MongoContext.createConnection(this.mongoConfig);
}
}
我们对上面只做了三件事:
1、导入开发好的组件
2、添加组件
3、连接数据库
编写测试代码
describe('/test/index.test.ts', () => {
it('测试用户组件', async () => {
let app = await createApp(
join(__dirname, 'fixtures', 'base-app'),
{},
Framework
);
const result = await createHttpRequest(app)
.get('/users')
.send({
operatorId: '60766c0d564f182c48fad23d'
})
.set('x-timeout', '5000');
expect(result.status).toBe(200);
console.log(result.body.data);
await close(app);
});
});
发布组件
推荐使用lerna的publish命令来发布组件。好处有如下几点:
1、管理不同npm包的依赖关系
2、当某个npm包版本变动,依赖它的npm包的版本也会跟着同步修改
3、会将源码自动发布到git仓库,并且会发布到tag版本。
4、可以一次性发布多个npm包
如果只是测试使用,使用npm直接发布也行。
准备工作
至少需要先准备好git远程仓库和npm包仓库。
建议先简单了解下lerna的使用和npm的发布使用
发布前注意检查一下组件package.json中的main入口是否有改回dist/index.js
发布之前还需要对项目进行编译,lerna exec npm run build —scope @lzy-component/*可以批量对组件名前缀为@lzy-conponent的组件进行编译
开始发布
## 先登记npm远程仓库,然后进行登陆
npm config set registry=[仓库地址]
npm login
## 使用npm模块进行发布
npm publish
## 使用lerna模块进行发布
lerna publish
使用组件
使用组件很简单,跟使用普通的npm包差不多。
## 你可以新增一个midwayjs项目
mw new mw-component-demo
cd mw-component-demo
## 添加刚刚发布出去的组件npm包
npm install @yxk-component/user
## 添加其他所需的依赖包
...
// projects\platform\src\configuration.ts
import { App, Config, Configuration } from '@midwayjs/decorator';
import { ILifeCycle } from '@midwayjs/core';
import { Application } from 'egg';
import { join } from 'path';
import * as user from '@lzy-component/user';
import { IMongoConfig, MongoContext } from '@lzy-plugin/mongo-context';
@Configuration({
imports: [user],
importConfigs: [join(__dirname, './config')],
})
export class ContainerLifeCycle implements ILifeCycle {
@App()
app: Application;
@Config('mongoConfig')
mongoConfig: IMongoConfig;
async onReady(): Promise<void> {
await MongoContext.createConnection(this.mongoConfig);
}
}