buy the book ribbon

附录 E:验证

每当我们教授和讨论这些技术时,一个反复出现的问题是“我应该在哪里进行验证?这属于我在领域模型中的业务逻辑,还是基础设施方面的问题?”

与任何架构问题一样,答案是:视情况而定!

最重要的考虑因素是我们希望保持代码的良好分离,以便系统的每个部分都简单。我们不希望用不相关的细节来 clutter 我们的代码。

究竟什么是验证?

当人们使用验证这个词时,他们通常指的是一个过程,他们通过这个过程测试操作的输入,以确保它们符合某些标准。符合标准的输入被认为是有效的,不符合标准的输入被认为是无效的。

如果输入无效,则操作无法继续,但应以某种错误退出。换句话说,验证是关于创建前提条件。我们发现将我们的前提条件分为三个子类型很有用:语法、语义和语用。

验证语法

在语言学中,语言的语法是支配语法句子结构的规则集。例如,在英语中,句子“Allocate three units of TASTELESS-LAMP to order twenty-seven”在语法上是正确的,而短语“hat hat hat hat hat hat wibble”则不是。我们可以将语法正确的句子描述为结构良好

这如何映射到我们的应用程序?以下是一些语法规则的示例

  • 一个 Allocate 命令必须具有订单 ID、SKU 和数量。

  • 数量是一个正整数。

  • SKU 是一个字符串。

这些是关于传入数据的形状和结构的规则。没有 SKU 或订单 ID 的 Allocate 命令不是有效的消息。它相当于短语“Allocate three to.”。

我们倾向于在系统边缘验证这些规则。我们的经验法则是,消息处理程序应始终只接收结构良好且包含所有必需信息的消息。

一种选择是将您的验证逻辑放在消息类型本身上

消息类上的验证 (src/allocation/commands.py)
from schema import And, Schema, Use


@dataclass
class Allocate(Command):

    _schema = Schema({  #(1)
        'orderid': str,
        'sku': str,
        'qty': And(Use(int), lambda n: n > 0)
     }, ignore_extra_keys=True)

    orderid: str
    sku: str
    qty: int

    @classmethod
    def from_json(cls, data):  #(2)
        data = json.loads(data)
        return cls(**_schema.validate(data))
  1. schema 库 让我们以一种很好的声明式方式描述我们消息的结构和验证。

  2. from_json 方法将字符串读取为 JSON,并将其转换为我们的消息类型。

但这可能会变得重复,因为我们需要两次指定我们的字段,因此我们可能希望引入一个辅助库,它可以统一我们消息类型的验证和声明

带有 schema 的命令工厂 (src/allocation/commands.py)
def command(name, **fields):  #(1)
    schema = Schema(And(Use(json.loads), fields), ignore_extra_keys=True)
    cls = make_dataclass(name, fields.keys())  #(2)
    cls.from_json = lambda s: cls(**schema.validate(s))  #(3)
    return cls

def greater_than_zero(x):
    return x > 0

quantity = And(Use(int), greater_than_zero)  #(4)

Allocate = command(  #(5)
    'Allocate',
    orderid=int,
    sku=str,
    qty=quantity
)

AddStock = command(
    'AddStock',
    sku=str,
    qty=quantity
  1. command 函数接受消息名称,以及消息有效负载字段的 kwargs,其中 kwarg 的名称是字段的名称,值是解析器。

  2. 我们使用 dataclass 模块中的 make_dataclass 函数来动态创建我们的消息类型。

  3. 我们将 from_json 方法修补到我们的动态 dataclass 上。

  4. 我们可以为 quantity、SKU 等创建可重用的解析器,以保持 DRY。

  5. 声明消息类型变成一行代码。

这是以丢失 dataclass 上的类型为代价的,因此请记住这种权衡。

Postel 定律和容错读取器模式

Postel 定律,或健壮性原则,告诉我们,“对你接受的东西要宽松,对你发出的东西要保守。” 我们认为这尤其适用于与我们其他系统集成的上下文中。这里的想法是,当我们将消息发送到其他系统时,我们应该严格,但是当我们从其他人那里接收消息时,我们应该尽可能宽容。

例如,我们的系统可以验证 SKU 的格式。我们一直在使用虚构的 SKU,如 UNFORGIVING-CUSHIONMISBEGOTTEN-POUFFE。这些遵循一个简单的模式:两个词,用破折号分隔,其中第二个词是产品类型,第一个词是形容词。

开发人员喜欢在他们的消息中验证这类东西,并拒绝任何看起来像无效 SKU 的东西。当一些无政府主义者发布名为 COMFY-CHAISE-LONGUE 的产品,或者当供应商的混乱导致运送 CHEAP-CARPET-2 时,这会在后续造成可怕的问题。

实际上,作为分配系统,SKU 的格式是什么与我们无关。我们所需要的只是一个标识符,因此我们可以简单地将其描述为一个字符串。这意味着采购系统可以随时更改格式,而我们不会在意。

同样的原则适用于订单号、客户电话号码等等。在大多数情况下,我们可以忽略字符串的内部结构。

同样,开发人员喜欢使用 JSON Schema 等工具验证传入的消息,或者构建验证传入消息并在系统之间共享消息的库。这同样不符合健壮性测试。

让我们想象一下,例如,采购系统向 ChangeBatchQuantity 消息添加了新字段,以记录更改的原因和负责更改的用户的电子邮件。

由于这些字段对分配服务无关紧要,我们应该简单地忽略它们。我们可以通过传递关键字参数 ignore_extra_keys=Trueschema 库中做到这一点。

这种模式,即我们只提取我们关心的字段并对其进行最小的验证,就是容错读取器模式。

提示
尽可能少地验证。只读取您需要的字段,并且不要过度指定其内容。这将帮助您的系统在其他系统随时间变化时保持健壮性。抵制在系统之间共享消息定义的诱惑:相反,使其易于定义您依赖的数据。有关更多信息,请参阅 Martin Fowler 关于 容错读取器模式 的文章。
Postel 总是对的吗?

提及 Postel 可能会让一些人非常激动。他们会 告诉你 Postel 正是互联网上一切都坏掉的原因,我们无法拥有美好的事物。有一天问问 Hynek 关于 SSLv3 的事。

我们喜欢容错读取器方法,特别是在我们控制的服务之间基于事件的集成的上下文中,因为它允许这些服务独立演进。

如果您负责对公共互联网开放的 API,则可能有充分的理由对您允许的输入更加保守。

在边缘验证

早些时候,我们说过我们希望避免用不相关的细节 clutter 我们的代码。特别是,我们不希望在我们的领域模型内部进行防御性编码。相反,我们希望确保在我们的领域模型或用例处理程序看到请求之前,请求已知是有效的。这有助于我们的代码长期保持清洁和可维护性。我们有时将此称为在系统边缘验证

除了保持代码清洁且没有无休止的检查和断言之外,请记住,在您的系统中游荡的无效数据是一个定时炸弹;它越深入,它可能造成的损害就越大,而您可用于响应它的工具就越少。

回到 [chapter_08_events_and_message_bus],我们说过消息总线是放置横切关注点的好地方,而验证就是一个完美的例子。以下是我们如何更改我们的总线来为我们执行验证

验证
class MessageBus:

    def handle_message(self, name: str, body: str):
        try:
            message_type = next(mt for mt in EVENT_HANDLERS if mt.__name__ == name)
            message = message_type.from_json(body)
            self.handle([message])
        except StopIteration:
            raise KeyError(f"Unknown message name {name}")
        except ValidationError as e:
            logging.error(
                f'invalid message of type {name}\n'
                f'{body}\n'
                f'{e}'
            )
            raise e

以下是我们如何从我们的 Flask API 端点使用该方法

API 冒出验证错误 (src/allocation/flask_app.py)
@app.route("/change_quantity", methods=['POST'])
def change_batch_quantity():
    try:
        bus.handle_message('ChangeBatchQuantity', request.body)
    except ValidationError as e:
        return bad_request(e)
    except exceptions.InvalidSku as e:
        return jsonify({'message': str(e)}), 400

def bad_request(e: ValidationError):
    return e.code, 400

以下是我们如何将其插入到我们的异步消息处理器中

处理 Redis 消息时的验证错误 (src/allocation/redis_pubsub.py)
def handle_change_batch_quantity(m, bus: messagebus.MessageBus):
    try:
        bus.handle_message('ChangeBatchQuantity', m)
    except ValidationError:
        print('Skipping invalid message')
    except exceptions.InvalidSku as e:
        print(f'Unable to change stock for missing sku {e}')

请注意,我们的入口点只关心如何从外部世界获取消息以及如何报告成功或失败。我们的消息总线负责验证我们的请求并将它们路由到正确的处理程序,而我们的处理程序则专门关注我们用例的逻辑。

提示
当您收到无效消息时,通常您能做的很少,只能记录错误并继续。在 MADE,我们使用指标来计算系统接收的消息数量,以及其中有多少消息被成功处理、跳过或无效。如果我们看到错误消息数量激增,我们的监控工具会提醒我们。

验证语义

虽然语法关注消息的结构,但语义是对消息中含义的研究。句子“Undo no dogs from ellipsis four”在语法上是有效的,并且与句子“Allocate one teapot to order five”具有相同的结构,但它是没有意义的。

我们可以将此 JSON blob 读取为 Allocate 命令,但无法成功执行它,因为它毫无意义

一条无意义的消息
{
  "orderid": "superman",
  "sku": "zygote",
  "qty": -1
}

我们倾向于在消息处理程序层使用基于契约的编程来验证语义问题

前提条件 (src/allocation/ensure.py)
"""
This module contains preconditions that we apply to our handlers.
"""

class MessageUnprocessable(Exception):  #(1)

    def __init__(self, message):
        self.message = message

class ProductNotFound(MessageUnprocessable):  #(2)
    """"
    This exception is raised when we try to perform an action on a product
    that doesn't exist in our database.
    """"

    def __init__(self, message):
        super().__init__(message)
        self.sku = message.sku

def product_exists(event, uow):  #(3)
    product = uow.products.get(event.sku)
    if product is None:
        raise ProductNotFound(event)
  1. 我们为意味着消息无效的错误使用一个通用的基类。

  2. 为此问题使用特定的错误类型可以更轻松地报告和处理错误。例如,很容易将 ProductNotFound 映射到 Flask 中的 404。

  3. product_exists 是一个前提条件。如果条件为 False,我们会引发错误。

这使我们服务层中的逻辑主流程保持清洁和声明式

服务中的 Ensure 调用 (src/allocation/services.py)
# services.py

from allocation import ensure

def allocate(event, uow):
    line = model.OrderLine(event.orderid, event.sku, event.qty)
    with uow:
        ensure.product_exists(event, uow)

        product = uow.products.get(line.sku)
        product.allocate(line)
        uow.commit()

我们可以扩展此技术以确保我们幂等地应用消息。例如,我们希望确保我们不会多次插入一批库存。

如果我们被要求创建已存在的批次,我们将记录警告并继续下一条消息

为可忽略的事件引发 SkipMessage 异常 (src/allocation/services.py)
class SkipMessage (Exception):
    """"
    This exception is raised when a message can't be processed, but there's no
    incorrect behavior. For example, we might receive the same message multiple
    times, or we might receive a message that is now out of date.
    """"

    def __init__(self, reason):
        self.reason = reason

def batch_is_new(self, event, uow):
    batch = uow.batches.get(event.batchid)
    if batch is not None:
        raise SkipMessage(f"Batch with id {event.batchid} already exists")

引入 SkipMessage 异常使我们能够在消息总线中以通用方式处理这些情况

总线现在知道如何跳过 (src/allocation/messagebus.py)
class MessageBus:

    def handle_message(self, message):
        try:
            ...
        except SkipMessage as e:
            logging.warn(f"Skipping message {message.id} because {e.reason}")

这里有几个需要注意的陷阱。首先,我们需要确保我们使用的是与我们用例的主要逻辑相同的 UoW。否则,我们就会让自己面临令人恼火的并发错误。

其次,我们应该尽量避免将所有业务逻辑都放入这些前提条件检查中。作为经验法则,如果一个规则可以在我们的领域模型内部进行测试,那么它应该在领域模型中进行测试。

验证语用

语用是对我们如何在上下文中理解语言的研究。在我们解析消息并掌握其含义之后,我们仍然需要在上下文中处理它。例如,如果您收到对拉取请求的评论说“我认为这非常勇敢”,则可能意味着审阅者钦佩您的勇气——除非他们是英国人,在这种情况下,他们试图告诉您,您正在做的事情非常冒险,只有傻瓜才会尝试。上下文决定一切。

验证回顾
验证对不同的人意味着不同的事情

在谈论验证时,请确保您清楚自己要验证什么。我们发现考虑语法、语义和语用很有用:消息的结构、消息的含义以及管理我们对消息的响应的业务逻辑。

尽可能在边缘验证

验证必填字段和数字的允许范围很无聊,我们希望将其排除在我们的漂亮干净的代码库之外。处理程序应始终只接收有效消息。

仅验证您需要的

使用容错读取器模式:仅读取您的应用程序需要的字段,并且不要过度指定其内部结构。将字段视为不透明的字符串可以为您带来很大的灵活性。

花时间编写验证助手

拥有一个很好的声明式方式来验证传入的消息并将前提条件应用于您的处理程序将使您的代码库更加清洁。值得投入时间来使无聊的代码易于维护。

将三种类型的验证分别放在正确的位置

验证语法可以发生在消息类上,验证语义可以发生在服务层或消息总线上,而验证语用则属于领域模型。

提示
一旦您在系统边缘验证了命令的语法和语义,领域就是进行其余验证的地方。语用验证通常是您的业务规则的核心部分。

在软件术语中,操作的语用通常由领域模型管理。当我们收到诸如“allocate three million units of SCARCE-CLOCK to order 76543”之类的消息时,该消息在语法上是有效的,在语义上也是有效的,但我们无法遵守,因为我们没有可用的库存。