NodeJS的DDD与CRUD对比案例 - Khalil Stemmler


当你开始一个新的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 的声明本质。如何完成它是抽象的,但是有效使用的无处不在的语言描述了允许域对象做什么以及何时做什么。