准备工作

开发环境:node12.x以上
开发语言:TypeScript、JavaScript
内置模块:
1)lerna (可选,用于管理各个模块的依赖关系、发布版本,一键发布推送到git仓库和npm仓库)
2)jest (必选,团队采用TDD模式开发,要求每个开发者都必须掌握一定的单元测试能力)
3)class-transformer (必选,数据转换工具,是项目中的常用工具)
4)class-validator (必选,数据验证工具,是项目中的常用工具)
5)mongoose+typegoose(可选,虽然框架底层封装了mongoose的操作,但有些业务场景需要开发者使用mongoose或typegoose更多的api来实现)

下载项目源代码

  1. git clone https://github.com/feiyueXH/lzy-midway-ddd.git

组件开发

下载组件模板代码

  1. cd packages
  2. git clone https://github.com/feiyueXH/lzy-component-template.git

组件项目结构

  1. .gitignore
  2. jest.config.js ## jest工具配置文件
  3. package-lock.json
  4. package.json
  5. tsconfig.json
  6. ├───src
  7. configuration.ts ## 自启动文件
  8. index.ts ## 入口文件
  9. interface.ts
  10. ├───application ## 应用层
  11. ├───checker
  12. interface.ts
  13. └───impl
  14. user.checker.ts
  15. ├───command ## 命令
  16. change-password.command.ts
  17. register-user.command.ts
  18. └───executor ## 命令执行者
  19. user.ts
  20. └───query ## 查询
  21. list-user.query.ts
  22. └───executor ## 查询执行者
  23. user.ts
  24. ├───config
  25. config.default.ts ## 配置文件
  26. ├───domain ## 领域层
  27. ├───aggregate ## 聚合(包含聚合根、实体、聚合根)
  28. user.ts
  29. └───event ## 领域事件
  30. user-registered.event.ts
  31. ├───infrastructure ## 基础设施层
  32. └───db
  33. └───mongo
  34. └───models ## 定义typegoose的Model
  35. user.ts
  36. └───web
  37. └───api ## 对外接口
  38. hello.controller.ts
  39. user.controller.ts
  40. └───test ## 测试代码

注意:组件项目中的.git文件夹需要自行删除,lerna项目下已经有.git文件夹,如果不删除会造成冲突

架构图

快速入门开发 - 图1

牛刀小试

功能需求:注册用户和查询用户列表
备注:注册用户表单内容:用户名、密码、邮箱

开发注册用户功能

因为注册用户功能是一个写操作,所以要走执行命令流程
开发流程为:命令—>命令执行者—>领域模型(聚合根、实体、值对象、领域事件)—>数据模型

命令(Command)
  1. // src\application\command\register-user.command.ts
  2. // ddd-cqrs是一个自行封装的核心模块,作用是为了提供ddd、cqrs架构下所需的基类、事件总线、命令总线、查询总线、事务处理、能够自动分拣持久化数据通用化仓储等等
  3. import { SuperCommand } from '@lzy-plugin/ddd-cqrs';
  4. import { Expose } from 'class-transformer';
  5. export class RegisterUserCommand extends SuperCommand {
  6. @Expose()
  7. username: string;
  8. @Expose()
  9. password: string;
  10. @Expose()
  11. email: string;
  12. }

命令执行者(Command Executor)
  1. // src\application\command\executor\user.ts
  2. import {
  3. CommonRepository, // 通用仓储
  4. SuperCommand,
  5. SuperCommandExecutor,
  6. RepositoryManager, // 仓储管理者
  7. SubscribeCommand
  8. } from '@lzy-plugin/ddd-cqrs';
  9. import { Inject, Provide } from '@midwayjs/decorator';
  10. import { User } from '../../../domain/aggregate/user';
  11. import { IUserChecker } from '../../checker/interface';
  12. import { RegisterUserCommand } from '../register-user.command';
  13. import { ChangePasswordCommand } from '../change-password.command';
  14. @SubscribeCommand([RegisterUserCommand, ChangePasswordCommand])// 订阅命令,命令总线会自动收集订阅
  15. @Provide()
  16. export class UserCommandExecutor extends SuperCommandExecutor {
  17. @Inject()
  18. userChecker: IUserChecker; // 用户校验器
  19. userRepository: CommonRepository<User>; //用户仓储
  20. public async init(repositoryManager: RepositoryManager): Promise<void> {
  21. this.userRepository = await repositoryManager.get(User);// 从仓储管理者获取用户仓储
  22. }
  23. public async executeCommand<C extends SuperCommand>(
  24. command: C
  25. ): Promise<void> {
  26. if (command instanceof RegisterUserCommand) {
  27. await this.registerUser(command);
  28. } else if (command instanceof ChangePasswordCommand) {
  29. await this.changePassword(command);
  30. }else {
  31. throw new Error('未实现业务逻辑');
  32. }
  33. }
  34. /**
  35. * 注册用户
  36. * @param command
  37. */
  38. private async registerUser(command: RegisterUserCommand): Promise<void> {
  39. const user = await User.create(User, command, this.userChecker);// 创建一个用户
  40. await this.userRepository.add(user);// 调用用户仓储添加用户,执行命令结束后,将自动持久化。
  41. }
  42. /**
  43. * 修改密码
  44. * @param command
  45. */
  46. private async changePassword(command: ChangePasswordCommand): Promise<void> {
  47. const user = await this.userRepository.get(command._id);
  48. user.changePassword(command.password); //从仓储内部实现了数据变化侦测,所以改完属性值后,命令执行完毕,仓储会自动持久化。
  49. }
  50. }

领域模型(Domain Model)
  1. // src\domain\aggregate\user.ts
  2. import {
  3. AggregateRoot,
  4. Entity,
  5. CommonException,
  6. ExceptionCodeEnum
  7. } from '@lzy-plugin/ddd-cqrs';
  8. import { Expose } from 'class-transformer';
  9. import { IsNotEmpty } from 'class-validator';
  10. import { IUserChecker } from '../../application/checker/interface';
  11. export class User extends AggregateRoot {//聚合根
  12. @Expose()
  13. @IsNotEmpty()
  14. username: string;
  15. @Expose()
  16. @IsNotEmpty()
  17. password: string;
  18. @Expose()
  19. @IsNotEmpty()
  20. email: string;
  21. changePassword(password: string): void {
  22. this.password = password;
  23. }
  24. static async create<E extends Entity>(
  25. clazz: new () => E,
  26. object: Record<string, any>,
  27. userChecker: IUserChecker
  28. ): Promise<E> {
  29. if (await userChecker.isExistedByUsername(object)) {
  30. throw new CommonException(
  31. ExceptionCodeEnum.DB_FAIL_CREATE_UNIQUE,
  32. '账号已被注册'
  33. );
  34. }
  35. return await Entity.create(clazz, object);
  36. }
  37. }

数据模型 (Data Model)
  1. // src\infrastructure\db\mongo\models\user.ts
  2. // 自行封装的ORM模块
  3. import { SuperModel, TypegooseModel } from '@lzy-plugin/mongo-context';
  4. import { prop } from '@typegoose/typegoose';
  5. import { Expose } from 'class-transformer';
  6. // 声明为TypegoooseModel,modelName必须与领域层中的实体类名一致,仓储才可以根据实体名称调用ORM持久化
  7. @TypegooseModel({ modelName: 'User', collectionName: 'users' })
  8. export class UserModel extends SuperModel {
  9. @Expose()
  10. @prop()
  11. username: string;
  12. @Expose()
  13. @prop()
  14. password: string;
  15. @Expose()
  16. @prop()
  17. email: string;
  18. }

以上代码就已经完成了注册用户的业务开发,接下来就是对外开发一个接口提供调用

  1. import { SuperController } from '@lzy-plugin/ddd-cqrs';
  2. import { Controller, Post, Body, ALL, Provide, Get } from '@midwayjs/decorator';
  3. import { plainToClass } from 'class-transformer';
  4. import { RegisterUserCommand } from '../../application/command/register-user.command';
  5. @Controller('/users')
  6. @Provide()
  7. export class UserController extends SuperController {
  8. @Post('/')
  9. async registerUser(@Body(ALL) body: any): Promise<void> {
  10. //创建命令
  11. const command: RegisterUserCommand = plainToClass(
  12. RegisterUserCommand,
  13. body
  14. );
  15. //通过命令总线发布命令,然后等待命令执行完毕
  16. await this.commandBus.send(command, { dbKey: 'admin' });
  17. //命令执行成功
  18. this.httpHelper.success();
  19. }
  20. }

开发查询用户列表功能

这是一个读操作,并不会写入任何数据,所以走执行查询流程
开发流程:查询—>查询执行者—>调用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);
  }
}