DTO、存储库和数据映射器在DDD中的作用 | Khalil Stemmler


在领域驱动设计中,对于在对象建模系统的开发中需要发生的每一件事情都有一个正确的工具。
负责处理验证逻辑的是什么?值对象。
你在哪里处理领域逻辑?尽可能使用实体,否则领域服务。

也许学习DDD最困难的方面之一就是能够确定特定任务所需的工具。
在DDD中,存储库,数据映射器和DTO是实体生命周期关键部分,使我们能够存储,重建和删除域实体。这种类型的逻辑称为“ 数据访问逻辑 ”。

对于使用MVC构建REST-ful CRUD API而不太关注封装ORM数据访问逻辑的开发人员,您将学习:

  • 当我们不封装ORM数据访问逻辑时发生的问题
  • 如何使用DTO来稳定API
  • 存储库如何充当复杂ORM查询的外观
  • 创建存储库和方法的方法
  • 数据映射器如何用于与DTO,域实体和ORM模型进行转换

我们如何在MVC应用程序中使用ORM模型?
我们看看一个MVC控制器的代码:

class UserController extends BaseController {
  async exec (req, res) => {
    try {
      const { User } = models;
      const { username, email, password } = req.body;
      const user = await User.create({ username, email, password });
      return res.status(201);
    } catch (err) {
      return res.status(500);
    }
  }
}

我们同意这种方法的好处是:

  • 这段代码非常容易阅读
  • 在小型项目中,这种方法可以很容易地快速提高工作效率

但是,随着我们的应用程序不断发展并变得越来越复杂,这种方法会带来一些缺点,可能会引入错误。

主要原因是因为缺乏关注点分离。这段代码负责太多事情:

  • 处理API请求(控制器责任)
  • 对域对象执行验证(此处不存在,但域实体值对象责任)
  • 将域实体持久化到数据库(存储库责任)

当我们为项目添加越来越多的代码时,我们注意为我们的类分配单一的责任变得非常重要。

场景:在3个单独的API调用中返回相同的视图模型
这是一个示例,其中缺乏对从ORM检索数据的封装可能导致引入错误。

假设我们正在开发我们的乙烯基交易应用程序,我们的任务是创建3个不同的API调用。

GET /vinyl?recent=6         - GET the 6 newest listed vinyl
GET /vinly/:vinylId/        - GET a particular vinyl by it's id
GET /vinyl/owner/:userId/   - GET all vinyl owned by a particular user

在每个API调用中,我们都需要返回Vinyl视图模型,所以让我们做第一个控制器:返回最新的乙烯基。

export class GetRecentVinylController extends BaseController {
  private models: any;

  public constructor (models: any) {
    super();
    this.models = models;
  }

  public async executeImpl(): Promise<any> {
    try {
      const { Vinyl, Track, Genre } = this.models;
      const count: number = this.req.query.count;

      const result = await Vinyl.findAll({
        where: {},
        include: [
          { owner: User, as: 'Owner', attributes: ['user_id', 'display_name'] },
          { model: Genre, as: 'Genres' },
          { model: Track, as: 'TrackList' },
        ],
        limit: count ? count : 12,
        order: [
          ['created_at', 'DESC']
        ],
      })

      return this.ok(this.res, result);
    } catch (err) {
      return this.fail(err);
    }
  }
}

如果您熟悉Sequelize,这可能是您的标准。
再实现一个根据Id获得实体的控制器:

export class GetVinylById extends BaseController {
  private models: any;

  public constructor (models: any) {
    super();
    this.models = models;
  }

  public async executeImpl(): Promise<any> {
    try {
      const { Vinyl, Track, Genre } = this.models;
      const vinylId: string = this.req.params.vinylId;

      const result = await Vinyl.findOne({
        where: {},
        include: [
          { model: User, as: 'Owner', attributes: ['user_id', 'display_name'] },
          { model: Genre, as: 'Genres' },
          { model: Track, as: 'TrackList' },
        ]
      })

      return this.ok(this.res, result);
    } catch (err) {
      return this.fail(err);
    }
  }
}

这两个类之间没有太大的不同,呃?
所以这绝对不遵循DRY原则,因为我们在这里重复了很多。
并且您可以预期第三个API调用将与此类似。
到目前为止,我们注意到的主要问题是:代码重复;另一个问题是......缺乏数据一致性!

请注意我们如何直接传回ORM查询结果?

return this.ok(this.res, result);

这就是为了响应API调用而返回给客户端的内容。
那么,当我们 在数据库上执行迁移并添加新列时会发生什么?更糟糕的是 - 当我们删除列或更改列的名称时会发生什么?
我们刚刚破坏了依赖它的每个客户端的API。

嗯......我们需要一个工具。
让我们进入我们的企业工具箱,看看我们发现了什么......
啊,DTO(数据传输对象)。

数据传输对象
数据传输对象是在两个独立系统之间传输数据的对象的(奇特)术语。
当我们关注Web开发时,我们认为DTO是View Models,因为它们是虚假模型。它们不是真正的域模型,但它们包含视图需要了解的尽可能多的数据。
例如,Vinyl视图模型/ DTO可以构建为如下所示:

type Genre = 'Post-punk' | 'Trip-hop' | 'Rock' | 'Rap' | 'Electronic' | 'Pop';

interface TrackDTO {
  number: number;
  name: string;
  length: string;
}

type TrackCollectionDTO = TrackDTO[];

// Vinyl view model / DTO, this is the format of the response
interface VinylDTO {
  albumName: string;
  label: string;
  country: string;
  yearReleased: number;
  genres: Genre[];
  artistName: string;
  trackList: TrackCollectionDTO;
}

之所以如此强大是因为我们只是标准化了我们的API响应结构。
我们的DTO是一份数据合同。我们告诉任何使用此API的人,“嘿,这将是您始终期望从此API调用中看到的格式”。
这就是我的意思。让我们看一下如何在通过id检索Vinyl的例子中使用它。

export class GetVinylById extends BaseController {
  private models: any;

  public constructor (models: any) {
    super();
    this.models = models;
  }

  public async executeImpl(): Promise<any> {
    try {
      const { Vinyl, Track, Genre, Label } = this.models;
      const vinylId: string = this.req.params.vinylId;

      const result = await Vinyl.findOne({
        where: {},
        include: [
          { model: User, as: 'Owner', attributes: ['user_id', 'display_name'] },
          { model: Label, as: 'Label' },
          { model: Genre, as: 'Genres' }
          { model: Track, as: 'TrackList' },
        ]
      });

      // Map the ORM object to our DTO
      const dto: VinylDTO = {
        albumName: result.album_name,
        label: result.Label.name,
        country: result.Label.country,
        yearReleased: new Date(result.release_date).getFullYear(),
        genres: result.Genres.map((g) => g.name),
        artistName: result.artist_name,
        trackList: result.TrackList.map((t) => ({
          number: t.album_track_number,
          name: t.track_name,
          length: t.track_length,
        }))
      }

     
// Using our baseController, we can specify the return type
     
// for readability.
      return this.ok<VinylDTO>(this.res, dto)
    } catch (err) {
      return this.fail(err);
    }
  }
}

那很好,但现在让我们考虑一下这类的责任。
这是一个controller,但它负责:
  • 定义如何坚持ORM模型映射到VinylDTO,TrackDTO和Genres。
  • 定义需要从Sequelize ORM调用中检索多少数据才能成功创建DTO。

这比controllers应该做的要多得多。
我们来看看Repositories和Data Mappers。
我们将从存储库开始。

存储库Repositories
存储库是持久性技术的外观(例如ORM),Repository是实体生命周期关键部分,它使我们能够存储,重建和删除域实体。Facade是一种设计模式术语,指的是为更大的代码体提供简化界面的对象。在我们的例子中,更大的代码体是域实体持久性和 域实体检索逻辑。

存储库在DDD和清洁架构中的作用:

在DDD和清洁体系结构中,存储库是基础架构层关注的问题。
一般来说,我们说repos 持久化并检索域实体。
1. 保存持久化

  • 跨越交叉点和关系表的脚手架复杂持久性逻辑。
  • 回滚失败的事务
  • 单击save(),检查实体是否已存在,然后执行创建或更新。

关于“创建如果不存在,否则更新”,这就是我们不希望我们域中的任何其他构造必须知道的复杂数据访问逻辑的类型:只有repos应该关心它。

2.检索
检索创建域实体所需的全部数据,我们已经看到了这一点,选择了include: []Sequelize的内容,以便创建DTO和域对象。将实体重建的责任委托给一个映射器。

编写存储库的方法
在应用程序中创建存储库有几种不同的方法。

1. 通用存储库接口
您可以创建一个通用存储库接口,定义您必须对模型执行的各种常见操作getById(id: string),save(t: T)或者delete(t: T)。

interface Repo<T> {
  exists(t: T): Promise<boolean>;
  delete(t: T): Promise<any>;
  getById(id: string): Promise<T>;
  save(t: T): Promise<any>;
}

从某种意义上说,这是一种很好的方法,我们已经定义了创建存储库的通用方法,但我们最终可能会看到数据访问层的细节泄漏到调用代码中。

原因是因为getById感觉就像感冒一样。如果我正在处理一个VinylRepo,我宁愿任务,getVinylById因为它对域的泛在语言更具描述性。如果我想要特定用户拥有的所有乙烯基,我会使用getVinylOwnedByUserId。
喜欢的方法getById是相当YAGNI
这导致我们成为创建存储库的首选方式。

2.按实体/数据库表的存储库
我喜欢能够快速添加对我正在工作的域有意义的方便方法,所以我通常会从一个苗条的基础存储库开始:

interface Repo<T> {
  exists(t: T): Promise<boolean>;
  delete(t: T): Promise<any>;
  save(t: T): Promise<any>;
}

然后使用其他更多关于域的方法扩展它。

export interface IVinylRepo extends Repo<Vinyl> {
  getVinylById(vinylId: string): Promise<Vinyl>;
  findAllVinylByArtistName(artistName: string): Promise<VinylCollection>;
  getVinylOwnedByUserId(userId: string): Promise<VinylCollection>;
}

为什么总是将存储库定义为接口是有益的,因为它遵循Liskov Subsitution Principle(可以使结构被替换),并且它使结构成为依赖注入

让我们继续创建我们的IVinylRepo:

import { Op } from 'sequelize'
import { IVinylRepo } from './IVinylRepo';
import { VinylMap } from './VinyMap';

class VinylRepo implements IVinylRepo {
  private models: any;

  constructor (models: any) {
    this.models = models;
  }

  private createQueryObject (): any {
    const { Vinyl, Track, Genre, Label } = this.models;
    return
      where: {},
      include: [
        { model: User, as: 'Owner', attributes: ['user_id', 'display_name'], where: {} },
        { model: Label, as: 'Label' },
        { model: Genre, as: 'Genres' },
        { model: Track, as: 'TrackList' },
      ]
    }
  }

  public async exists (vinyl: Vinyl): Promise<boolean> {
    const VinylModel = this.models.Vinyl;
    const result = await VinylModel.findOne({ 
      where: { vinyl_id: vinyl.id.toString() }
    });
    return !!result === true;
  }

  public delete (vinyl: Vinyl): Promise<any> {
    const VinylModel = this.models.Vinyl;
    return VinylModel.destroy({ 
      where: { vinyl_id: vinyl.id.toString() }
    })
  }

  public async save(vinyl: Vinyl): Promise<any> {
    const VinylModel = this.models.Vinyl;
    const exists = await this.exists(vinyl.id.toString());
    const rawVinylData = VinylMap.toPersistence(vinyl);

    if (exists) {
      const sequelizeVinyl = await VinylModel.findOne({ 
        where: { vinyl_id: vinyl.id.toString() }
      });

      try {
        await sequelizeVinyl.update(rawVinylData);
        // scaffold all of the other related tables (VinylGenres, Tracks, etc)
       
// ...
      } catch (err) {
       
// If it fails, we need to roll everything back this.delete(vinyl);
      }
    } else  {
      await VinylModel.create(rawVinylData);
    }

    return vinyl;
  }

  public getVinylById(vinylId: string): Promise<Vinyl> {
    const VinylModel = this.models.Vinyl;
    const queryObject = this.createQueryObject();
    queryObject.where = { vinyl_id: vinyl.id.toString() };
    const vinyl = await VinylModel.findOne(queryObject);
    if (!!vinyl === false) return null;
    return VinylMap.toDomain(vinyl);
  }

  public findAllVinylByArtistName (artistName: string): Promise<VinylCollection> {
    const VinylModel = this.models.Vinyl;
    const queryObject = this.createQueryObject();
    queryObjectp.where = { [Op.like]: `%${artistName}%` };
    const vinylCollection = await VinylModel.findAll(queryObject);
    return vinylCollection.map((vinyl) => VinylMap.toDomain(vinyl));
  }

  public getVinylOwnedByUserId(userId: string): Promise<VinylCollection> {
    const VinylModel = this.models.Vinyl;
    const queryObject = this.createQueryObject();
    queryObject.include[0].where = { user_id: userId };
    const vinylCollection = await VinylModel.findAll(queryObject);
    return vinylCollection.map((vinyl) => VinylMap.toDomain(vinyl));
  }
}

看到我们封装了我们的sequelize数据访问逻辑?我们已经不再需要重复编写includes,因为现在所有必需的 include语句都在这里。
我们也提到过VinylMap。让我们快速看一下数据映射器Mapper的责任。

数据映射器
Mapper的职责是进行所有转换:

  • 从Domain到DTO
  • 从域到持久性
  • 从持久性到域

这是我们的VinylMap样子:

class VinylMap extends Mapper<Vinyl> {
  public toDomain (raw: any): Vinyl {
    const vinylOrError = Vinyl.create({

    }, new UniqueEntityID(raw.vinyl_id));
    return vinylOrError.isSuccess ? vinylOrError.getValue() : null;
  }

  public toPersistence (vinyl: Vinyl): any {
    return {
      album_name: vinyl.albumName.value,
      artist_name: vinyl.artistName.value
    }
  }

  public toDTO (vinyl: Vinyl): VinylDTO {
    return {
      albumName: vinyl.albumName,
      label: vinyl.Label.name.value,
      country: vinyl.Label.country.value
      yearReleased: vinyl.yearReleased.value,
      genres: result.Genres.map((g) => g.name),
      artistName: result.artist_name,
      trackList: vinyl.TrackList.map((t) => TrackMap.toDTO(t))
    }
  }
}

好的,现在让我们回过头来使用我们的VinylRepo和重构我们的控制器VinylMap。

export class GetVinylById extends BaseController {
  private vinylRepo: IVinylRepo;

  public constructor (vinylRepo: IVinylRepo) {
    super();
    this.vinylRepo = vinylRepo;
  }

  public async executeImpl(): Promise<any> {
    try {
      const { VinylRepo } = this;
      const vinylId: string = this.req.params.vinylId;
      const vinyl: Vinyl = await VinylRepo.getVinylById(vinylId);
      const dto: VinylDTO = VinylMap.toDTO(vinyl);
      return this.ok<VinylDTO>(this.res, dto)
    } catch (err) {
      return this.fail(err);
    }
  }
}

源码: