发布于 2026-01-06 7 阅读
0

对象设计风格指南摘要:如何创建和使用对象

对象设计风格指南概要

如何创建和使用对象

如何创建和使用对象

我最近在读一本很有意思的书,叫《对象设计风格指南》,作者是马蒂亚斯·诺巴克 (Matthias Noback),讲的是如何才能把对象设计得尽善尽美。所以我决定在这里跟大家分享一些我觉得比较有用的技巧和指导。当然,如果你想深入了解这个主题,我建议你通读全书。
图像

1º 面向对象编程概念简介

本书中,继承的作用很小,尽管它被认为是面向对象编程的基石之一。实际上,使用继承往往会导致设计混乱。
本书主要在以下两种情况下使用继承:

  • 在定义依赖项接口时(依赖注入和倒置)
  • 在定义对象层次结构时,例如定义继承自内置异常类的自定义异常时,我们通常会主动阻止开发者继承我们的类。您可以通过在类名前添加 `final` 关键字来实现这一点。稍后会对此进行更详细的解释。强烈建议使用组合而非继承。

关于单元测试的基本结构,这里留出一点空间:
每个测试方法的基本结构是 Arrange(准备)- Act(执行)- Assert(断言):
1. Arrange:将我们正在测试的对象(也称为 SUT,即被测对象)置于某个已知的状态。2
. Act:调用其某个方法。3
. Assert:对最终状态进行一些断言。

事情变得棘手了。物体分为两种类型:

  • 服务对象要么执行任务,要么返回信息。这类对象只需创建一次,即可被多次使用,但其自身属性不可更改。它们的生命周期非常简单。一旦创建,它们就可以像执行特定任务的小型机器一样无限期地运行。这类服务是不可变的。服务对象是执行者,它们的名称通常表明其功能:渲染器、计算器、存储库、调度器等等。
  • 这类对象用于存储数据,并可选择性地提供一些用于操作或检索这些数据的行为。第一类对象使用这类对象来完成其任务。这些对象是服务所使用的素材。它们有两种子类型:值对象和模型/实体,但我们暂且不深入探讨。

2º 关注服务

关于如何提供优质服务,有很多建议,我将对它们进行简要概括:

  • 要创建服务,请使用依赖注入,以便在实例化后立即使用服务并进行测试替身。因此,依赖项需要显式声明。以下是一个示例,您可以看到参数 `$appConfig` 仅用于获取缓存目录……因此,请确保只注入服务实际需要的值,而不是注入整个配置对象。
interface Logger
{
    public function log(string $message): void;
}

final class FileLogger implements Logger
{
    private Formatter $formatter;

    // Formatter is a dependency of FileLogger
    public function __ construct(Formatter $formatter) 
    {
        $this->formatter = $formatter;
    }

    public function log(string $message): void
    {
        formattedMessage = $this->formatter->format($message) ;
        // ….
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 尽可能将所有相关的配置值放在一起。服务不应该注入整个全局配置对象,而应该只注入它需要的值。

错误的方法

final class MySQLTableGateway
{
    public function __construct(
        string $host,
        int $port,
        string $username,
        string $password,
        string $database,
        string $table
    ) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

好方法

final class MySQLTableGateway
{
    public function __construct(
        ConnectionConfiguration $connectionConfiguration,
        string $table
    ) {
        // $table is the name of the table, It isn’t necessary to make the connection 
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 避免使用服务定位器(可以从中检索其他服务的服务),并显式注入所需的依赖项。
  • 所有构造函数参数都应该是必需的,否则代码会变得不必要地复杂。如果你确实想将其作为可选依赖项,可以使用空对象
  • 服务应该是不可变的,也就是说,一旦服务完全实例化,就不可能更改,因为其行为可能非常难以预测。

所以……请避免类似这样的情况:

final class EventDispatcher
{
    private array $listeners = [];

    public function addListener(
        string $event,
        callable $listener
    ): void {
        $this->listeners[event][] = $listener;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 仅当构造函数中出现验证错误时才分配属性或抛出异常。
final class FileLogger implements Logger
{
    private string $logFilePath;

    public function __construct(string $logFilePath)
    {
        // $logFilePath should be properly set up, so we just need a safety check
        if (! is_writable($logFilePath)) {
            throw new InvalidArgumentException(
                'Log file path  . $logFilePath .  should be writable
            );
        }
        $this->logFilePath = $logFilePath;
    }

    public function log(string message): void
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 理想情况下,应创建对象以避免隐藏依赖项,例如,使用 json_encode() 函数或 PHP 中的 DateTime 类。

错误的方法

final class ResponseFactory
{
    public function createApiResponse(array $data): Response
    {
        // json_encode is a hidden dependency
        return new Response(
        json_encode(
            $data,
            JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT), ['Content-Type' => 'application/json']
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

好方法

final class JsonEncoder
{
    / **
    * throws RuntimeException
    */
    public function encode(array $data): string
    {
        try {
            return json_encode(
                $data,
                JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT
            );
        // we can throw our own exception, with more specific info
        } catch (RuntimeException previous) {
            throw new RuntimeException(
                'Failed to encode data: ' . var_export($data, true),
                0,
                previous
            );
        }
    }
}

final class ResponseFactory
{
    private JsonEncoder $jsonEncoder;

    // JsonEncoder can be injected as a dependency
    public function __construct(JsonEncoder $jsonEncoder)
    {
        $this->jsonEncoder = $jsonEncoder;
    }

    public function createApiResponse(data): Response
    {
        return new Response($this->jsonEncoder->encode($data));
    }
}
Enter fullscreen mode Exit fullscreen mode

你可以对 date() 和语言中的大型核心实用程序执行相同的操作,这样你的应用程序层就会完全解耦。

3º 其他物体

3.1 值对象和模型/实体

主要建议如下:

  • 在构造函数中验证对象,可以确保应用程序中只有有效对象,每个对象都符合预期。如果数据无效,应该在构造函数中抛出异常。本书建议避免使用自定义异常来处理无效参数异常,因为这类运行时异常表明……更多内容请参见下文。
final class Coordinates
{
    public function _construct(float $latitude, float $longitude)
    {
        if ($latitude > 90 || $latitude < -90) {
            throw new InvalidArgumentException(
                'Latitude should be between -90 and 90'
            );
        }
        $this->latitude = $latitude;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 不要使用属性填充词,稍后我们会通过示例来了解它们在哪些情况下可以使用。
  • 实体/模型应该具有唯一 ID,而值对象则不具备这种特性,因为它们只是封装了一个或多个原始类型的值。
  • 为了给构造函数增加更多语义,出现了命名构造函数,这些是具有特定领域名称的静态方法,允许你的代码拥有比典型的 new class() 更好的名称。

标准方式

$salesOrder = new SalesOrder();
Enter fullscreen mode Exit fullscreen mode

更好的方法

$salesOrder = SalesOrder::place();
Enter fullscreen mode Exit fullscreen mode

你可以将 __construct 方法设为私有,以避免使用它,并在 place() 方法内部调用构造函数。

final class DecimalValue
{
    private int value;
    private int precision;

    private function __construct(int $value, int $precision)
    {
        this.value = value;
        Assertion.greaterOrEqualThan($precision, 0);
        $this->precision = $precision;
    }

    public static function fromInt(
        int $value,
        int $precision
    ): DecimalValue {
        return new DecimalValue($value, $precision);
    }

    public static function fromFloat(
        float $value,
        int $precision
    ): DecimalValue (
        return new DecimalValue(
            (int) round($value * pow(10, precision)),
            $precision
        ):
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 值对象的优点之一是,如果在其构造函数中进行验证,则在看到值对象时,您就知道它包含已验证的信息,而无需在代码的其他位置验证此信息。
  • 测试对象和构造函数的行为时,要模拟它们会失败的情况,不要仅仅为了检查值是否正确而创建测试。
public function it_can_be_constructed(): void
{
    $coordinates = new Coordinates(60.0, 100.0);
    assertEquals(60.0, $coordinates->latitude());
    assertEquals(100.0, $coordinates->longitude());
}
Enter fullscreen mode Exit fullscreen mode
  • 总之,值对象不仅表示领域概念,它们还可以出现在应用程序的任何位置。值对象是一个不可变对象,它封装了基本类型的值。

3.2 DTO(数据传输对象)

3.1 节的规则并不适用于这种类型的对象,即数据目标对象 (DTO)。在值对象和模型中,我们追求的是数据的一致性和有效性,而在 DTO 中,我们只需要(顾名思义)将数据从一个点传递到另一个点。

  • 可以使用常规构造函数创建 DTO。
  • 它的属性可以逐一设置。
  • 它的所有属性都已公开,因此请将其设为公开,并直接访问它们,无需使用 getter 方法。
  • 它的属性只包含原始类型的值。
  • 属性可以选择性地包含其他 DTO 或简单的 DTO 数组。
  • 必要时可以使用属性填充符。
final class ScheduleMeetup
{
    public string $title;
    public string $date;

    public static function fromFormData(
        array $formData
    ): ScheduleMeetup {
        $scheduleMeetup = new ScheduleMeetup();
        $scheduleMeetup->title = $formData['title'];
        $scheduleMeetup->date = $formData['date'];

        return $scheduleMeetup;
    }
}
Enter fullscreen mode Exit fullscreen mode

对象包含方法,方法中包含行为。方法分为两种:查询方法用于检索信息,命令方法用于执行任务。但两者都可以使用相同的“模板”进行设计,即:
1. 检查参数,如有错误则抛出错误。2
. 执行方法需要执行的操作,必要时抛出错误。3
. 检查后置条件。如果测试完善,则无需进行此步骤,但例如,在处理遗留代码时,进行后置条件检查对于安全检查非常有用。4
. 如果是查询方法,则返回结果。

您已经看到异常处理是代码的重要组成部分,在某些情况下使用自定义异常非常有用:
1. 如果您想捕获更高层级的特定异常类型。

try {
    // possibly throws ‘SomeSpecific’ exception
} catch (SomeSpecific $exception) {
    // …
}
Enter fullscreen mode Exit fullscreen mode

2º 如果存在多种方法来实例化同一种类型的异常

final class CouldNotDeliverOrder extends RuntimeException
{
    public static function itWasAlreadyDelivered(): CouldNotDeliverOrder
    {
        // ...
    }
    public static function insufficientQuantitiesInStock(): CouldNotDeliverOrder
    {
         //...
    }
}
Enter fullscreen mode Exit fullscreen mode

3. 如果你想使用命名构造函数来实例化异常

final class CouldNotFindProduct extends RuntimeException
{
    public static function withId(
        ProductId $productld
    ): CouldNotFindProduct (
        return new CouldNotFindProduct('Could not find a product with ID . $productld);
    }
}
throw CouldNotFindProduct .withId(/* ... */);
Enter fullscreen mode Exit fullscreen mode

而且,您不必在异常类的名称中放入“Exception”,而是使用像 InvalidEmailAddress 或 CouldNotFindProduct 这样的明确名称。

好了,就这些了,书中还有很多例子,所以我强烈建议你们去看看。如果你们想要本书的第二部分,请在评论区告诉我

来源及更多信息

文章来源:https://dev.to/migueldevelopez/object-design-style-guide-summary-42bl