DTO、儲存庫和資料對映器在DDD中的作用 | Khalil Stemmler

banq發表於2019-06-21

在領域驅動設計中,對於在物件建模系統的開發中需要發生的每一件事情都有一個正確的工具。

負責處理驗證邏輯的是什麼?值物件。

你在哪裡處理領域邏輯?儘可能使用實體,否則領域服務。

也許學習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);
    }
  }
}

原始碼:

相關文章