NodeJS的DDD與CRUD對比案例 - Khalil Stemmler

banq發表於2019-05-26

當你開始一個新的Node.js專案時,你先從什麼開始?
您是從資料庫架構開始的嗎?
你是從RESTful API開始的嗎?
你是從Model開始的嗎?

REST-first Design(REST優先設計)是一個專門術語,我一直用它來描述Domain-Driven Design(領域驅動設計)專案與REST-first CRUD專案在程式碼級別上的區別。

REST代表“Representational State Transfer”,這是一種使用HTTP在Web上設計API的架構風格。

在本文中,我將解釋RREST-first Design的程式碼庫是什麼樣的,它的必要性以及它與Domain-Driven Designed專案的區別。

命令正規化
您可能已經在生活中編寫了許多命令式程式碼,這通常是我們開始程式設計時開始學習的第一件事。命令式程式碼主要關注“我們如何”做某事。我們需要非常清楚地瞭解程式的狀態如何變化。

例如用命令式實現:找到陣列中的最大數量:

const numbers = [1,2,3,4,5];
let max = -1;
for (let i = 0; i < numbers.length; i++) {
  if (numbers[i] > max) {   
    max = numbers[i] 
  }
}

因為指令式程式設計要求您指定確切的命令來指定程式狀態如何更改,在本例中,我們:
  • 有一個數字列表
  • 從0開始建立一個for迴圈
  • 將i增加到numbers.length
  • 如果當前索引處的數字大於最大值,那麼我們將其設定為最大值

這就是我們用命令式程式碼做的事情。我們定義“如何”。REST-first設計通常是自然的命令式。

宣告正規化
宣告性程式設計更關注“什麼”。
因此,宣告性程式碼更加“冗長”,並且為了表達性而抽象出很多細節。
讓我們看一下同樣的例子。

const numbers = [1,2,3,4,5]
const max = numbers.reduce((a,b) => Math.max(a,b))


“這兩個例子中的哪一個會讓非程式設計師更快理解?”(當然是後者),在此示例中,語言更好地描述了“什麼”而不是命令式等效?這就是宣告性程式設計的美妙之處。程式碼更易讀,程式意圖更容易理解(如果操作被恰當地命名)。

宣告式樣式程式碼是使用領域驅動設計設計軟體的主要好處之一。

REST優先設計
當我們構建RESTful應用程式時,我們傾向於更多地考慮從以下任一方面設計應用程式:

  • 資料庫
  • API呼叫

因此,我們傾向於將大部分業務邏輯放在控制器或服務中。

你可能還記得Bob叔叔的“清潔架構”,對於控制器而言,這絕對是禁忌。如果您閱讀他的同名書籍,您可能會想起將所有域邏輯置於服務中的潛在服務導向謬誤(提示:貧血領域模型)。

但如果是下面情況:

  • 我們想要快速獲得一些東西
  • 我們使用了一個框架,如Nest.js
  • 我們想要回應原型應用
  • 我們正在開發小型應用程式
  • 我們正在研究硬體軟體問題中的#1或#2 問題

它足以滿足大量專案的需求!但是,對於具有複雜業務規則和策略的複雜域,隨著時間的推移,這有可能變得非常難以改變和擴充套件。

在REST優先的CRUD應用程式中,我們幾乎只編寫命令式程式碼來滿足業務用例。我們來看看它的樣子。
假設我們正在開發一個Customers可以租用的應用程式Movies。使用Express.jsSequelize ORM設計REST優先,我的程式碼可能如下所示:

class MovieController {
  public async rentMovie (movieId: string, customerId: string) {
    // Sequelize ORM models
    const { Movie, Customer, RentedMovie, CustomerCharge } = this.models;

    // Get the raw orm records from Sequelize
    const movie = await Movie.findOne({ where: { movie_id: movieId }});
    const customer = await Customer.findOne({ where: { customer_id: customerId }});

    // 401 error if not found
    if (!!movie === false) {
      return this.notFound('Movie not found')
    }

    // 401 error if not found
    if (!!customer === false) {
      return this.notFound('Customer not found')
    }

    // Create a record which signified a movie was rented
    await RentedMovie.create({
      customer_id: customerId,
      movie_id: movieId
    });

    // Create a charge for this customer.
      await CustomerCharge.create({
      amount: movie.rentPrice
    })

    return this.ok();
  }
}

在這個程式碼示例中,我們傳入一個 movieId和一個 customerId,然後拉出我們知道我們將需要使用的相應Sequelize模型。我們進行快速的空檢查,如果返回兩個模型例項,我們將建立一個RentedMovie和一個CustomerCharge。

這是快速而又髒的,它向您展示了我們能夠以多快的速度完成並執行REST優先

但是,一旦我們新增業務規則,事情就開始變得具有挑戰性。讓我們為此新增一些約束。考慮到以下Customer情況不允許租借電影:

A)一次租用最多的電影數量(但這是可配置的)
B)有未支付的餘額。

我們究竟能如何強制執行此業務邏輯?一種原始的方法是直接在我們MovieController的purchaseMovie方法中強制執行它。

class MovieController extends BaseController {
  constructor (models) {
    super();
    this.models = models;
  }
  public async rentMovie () {
    const { req } = this;
    const { movieId } = req.params['movie'];
    const { customerId } = req.params['customer'];

    // We need to pull out one more model,
    // CustomerPayment
    const { 
      Movie, 
      Customer, 
      RentedMovie, 
      CustomerCharge, 
      CustomerPayment 
    } = this.models;

    const movie = await Movie.findOne({ where: { movie_id: movieId }});
    const customer = await Customer.findOne({ where: { customer_id: customerId }});

    if (!!movie === false) {
      return this.notFound('Movie not found')
    }

    if (!!customer === false) {
      return this.notFound('Customer not found')
    }

    // Get the number of movies that this user has rented
    const rentedMovies = await RentedMovie.findAll({ customer_id: customerId });
    const numberRentedMovies = rentedMovies.length;

    // Enforce the rule
    if (numberRentedMovies >= 3) {
      return this.fail('Customer already has the maxiumum number of rented movies');
    }

    // Get all the charges and payments so that we can 
    // determine if the user still owes money
    const charges = await CustomerCharge.findAll({ customer_id: customerId });
    const payments = await CustomerPayment.findAll({ customer_id: customerId });

    const chargeDollars = charges.reduce((previousCharge, nextCharge) => {
      return previousCharge.amount + nextCharge.amount;
    });

    const paymentDollars = payments.reduce((previousPayment, nextPayment) => {
      return previousPayment.amount + nextPayment.amount;
    })

    // Enforce the second business rule
    if (chargeDollars > paymentDollars) {
      return this.fail('Customer has outstanding balance unpaid');
    }

    // If all else is good, we can continue
    await RentedMovie.create({
      customer_id: customerId,
      movie_id: movieId
    });
    
    await CustomerCharge.create({
      amount: movie.rentPrice
    })

    return this.ok();
  }
}

有幾個缺點:

1. 缺乏封裝
在開發與這些規則相交的新功能時,另一位開發人員可能會無意中繞過我們的域邏輯和業務規則,因為它存在一個不應該存在的地方。
我們可以輕鬆地將此域邏輯移動到服務。這將是一個小改進,但實際上,它只是重新定位問題發生的地方,因為其他開發人員仍然能夠在單獨的模組中編寫我們剛剛編寫的程式碼,並規避業務規則。
有更多的理由。如果您想更多地瞭解服務如何失控,請閱讀此內容

需要有一個地方來決定一個Customer人可以做什麼行動,那就是領域模型。

2. 缺乏可發現性
當您第一次檢視類及其方法時,應該準確地向您描述該類的功能和限制。當我們共同定位Customer基礎設施問題(控制器)中的功能和規則時,我們會失去一些可以發現的Customer功能以及何時可以執行此功能。

3. 缺乏靈活性
您如果希望您的應用程式是多平臺的,與舊系統整合或將您的應用程式作為桌面應用程式提供,我們需要確保沒有任何業務邏輯存在於控制器中,而是駐留在域層

CRUD優先設計是一種“事務指令碼”方法
在企業軟體領域,Martin Fowler稱之為事務指令碼,事務指令碼方法是我們用來編寫所有後端程式碼的單一方法。REST-first Design(通常是設計)是一個事務指令碼。

我們如何改進?我們使用領域模型。

DDD
在域建模中,主要好處之一是用於指定業務規則的宣告性語言變得如此富有表現力,以至於我們沒有時間新增新的功能和規則。這也使得我們的業務邏輯更具有可讀性。

如果我們採用前面的例子並透過DDD鏡頭觀察它,控制器程式碼可能看起來更像這樣:

class MovieController extends BaseController {
  private movieRepo: IMovieRepo;
  private customerRepo: ICustomerRepo;
  
  constructor (movieRepo: IMovieRepo, customerRepo: ICustomerRepo) {
    super();
    this.movieRepo = movieRepo;
    this.customerRepo = customerRepo;
  }

  public async rentMovie () {
    const { req, movieRepo, customerRepo } = this;
    const { movieId } = req.params['movie'];
    const { customerId } = req.params['customer'];

    const movie: Movie = await movieRepo.findById(movieId);
    const customer: Customer = await customerRepo.findById(customerId);

    if (!!movie === false) {
      return this.fail('Movie not found')
    }

    if (!!customer === false) {
      return this.fail('Customer not found')
    }

    // The declarative magic happens here.
    const rentMovieResult: Result<Customer> = customer.rentMovie(movie);

    if (rentMovieResult.isFailure) {
      return this.fail(rentMovieResult.error)
    } else {
      // if we were using the Unit of Work pattern, we wouldn't 
      // need to manually save the customer at the end of the request.
      await customerRepo.save(customer);
      return this.ok();
    }
  }
}


我們不再需要擔心:
  • 如果Customer有超過最大租借電影數量
  • 如果Customer已經支付了他們的賬單
  • Customer在他們租借電影后開帳單。


這是DDD 的宣告本質。如何完成它是抽象的,但是有效使用的無處不在的語言描述了允許域物件做什麼以及何時做什麼。
 

相關文章