阅读 30

使用TypeScript和InversifyJS在Node.js中实现SOLID和洋葱架构<上>

在本文中,我们将描述一种称为洋葱架构的架构。洋葱架构是一种遵循SOLID原则的软件应用体系结构。它广泛地使用了依赖注入原理,并且深刻受到了领域驱动设计(DDD)原理和一些函数式编程的影响。

先决条件

下一节描述了一些软件设计原则和设计模式,我们必须学习这些知识才能理解洋葱架构。

关注点分离(SoC)原则

小到一个函数,大到一个类,再或者是一个包,甚至更大的是一个层,都可以看作是一个关注点,关注点常见划分的手段有两种:功能(职责)和业务语义。平时说的边界也是在分离各自己的关注点,划分边界也是体现了单一职责。

业务语义在领域建模中经常使用到,根据业务语义进行拆分,不同的对象放在不同的域内,如有订单域、商品域、交易域、结算域等等,它们的业务含义是不一样的。

功能划分是给对象分配职责,到底这个功能要放在哪里,GRASP给出一些模式可以参考。

到这里,分离关注点总的思路可以归结两点:拆分+归类,这两点是人认识事物重要的思维模式。如何拆分也可以按照功能和业务语义进行拆分,归类是在拆分的基础上做合并和抽象,哪些要放在一起,哪些是具有层次依赖的,这些最终是要形成一个整体。

SOLID 原则

SOLID是缩写单词,它代表以下五个原则:

SOLID

职责单一原则 Single Responsibility Principle (SRP)

  • A module should have one, and only one, reason to change.
  • A module should be responsible to one, and only one, actor.

一个类,只对一件事负责,并把这件事做好,其只有一个能引起它变化的原因。

当需求改变的时候,就意味着一些代码得重构了,也意味着一些类需要做出修改去满足新的需求。一个类做的事情越多,那么它被修改的可能性也就越大,这些修改也就越麻烦。有些类之间的耦合依赖特别强,改变其中一个类可能要修改一连串其他的类。

那么如何理解职责?一个职责可以定义成一个需要重构代码的原因。当类中任何一块代码有了其它的职责,那我们就需要把这个代码从这个类中移出去。

下面的示例是一个TypeScript类,它定义一个Person;该类不应包含电子邮件验证的功能,因为它与人的行为无关:


class Person {
    public name : string;
    public surname : string;
    public email : string;
    constructor(name : string, surname : string, email : string){
        this.surname = surname;
        this.name = name;
        if(this.validateEmail(email)) {
          this.email = email;
        }
        else {
            throw new Error("Invalid email!");
        }
    }
    validateEmail(email : string) {
        var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
        return re.test(email);
    }
    greet() {
        alert("Hi!");
    }
}
复制代码

我们可以通过从Person类中删除电子邮件验证的功能,并创建一个具有该功能的新Email类来改进上述类:


class Email {
    public email : string;
    constructor(email : string){
        if(this.validateEmail(email)) {
          this.email = email;
        }
        else {
            throw new Error("Invalid email!");
        }        
    }
    validateEmail(email : string) {
        var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
        return re.test(email);
    }
}

class Person {
    public name : string;
    public surname : string;
    public email : Email;
    constructor(name : string, surname : string, email : Email){
        this.email = email;
        this.name = name;
        this.surname = surname;
    }
    greet() {
        alert("Hi!");
    }
}
复制代码

确保类单一职责,这使得它在默认情况下非常容易去扩展/改进。

开闭原则 Open-Closed Principle (OCP)

软件架构应该对扩张开放,对修改关闭。

亦可以简单理解为通过新增代码修改系统行为,而非修改原来的代码。

以下是不符合开闭原则的一段代码的示例:


class Rectangle {
    public width: number;
    public height: number;
}

class Circle {
    public radius: number;
}

function getArea(shapes: (Rectangle|Circle)[]) {
    return shapes.reduce(
        (previous, current) => {
            if (current instanceof Rectangle) {
                return current.width * current.height;
            } else if (current instanceof Circle) {
                return current.radius * current.radius * Math.PI;
            } else {
                throw new Error("Unknown shape!")
            }
        },
        0
    );
}
复制代码

前面的代码段描述的是计算两个形状(矩形和圆形)的面积。如果我们尝试增加对一种新形状的支持,我们将扩展我们的程序。这样做的问题是我们需要修改getArea函数。

这个问题的解决办法是采用面向对象编程中的多态思想:


interface Shape {
    area(): number;
}

class Rectangle implements Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }
}

class Circle implements Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }
}

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
复制代码

新的解决方案使得我们能够添加对新的形状(对扩展开放),而无需修改现有的源代码(对修改关闭)的支持。

里氏替换原则 Liskov Substitution Principle (LSP)

程序中的对象应该可以用其子类型的实例替换,而不会改变该程序的正确性。

里氏替换原则还鼓励我们在面向对象编程中利用多态。在前面的示例中:


function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
复制代码

我们使用接口Shape来确保我们的程序是对扩展开放的,但是对修改是关闭的。 里氏替换原则告诉我们,我们应该能够将Shape的任何子类型传递给getArea函数,而无需更改该程序的正确性。在诸如TypeScript之类的静态编程语言中,编译器将为我们检查子类型的正确实现(例如,如果Shape的实现缺少area方法,则将得到编译错误)。这意味着我们无需做任何手动工作即可确保我们的应用程序符合里氏替换原则。

接口隔离原则 Interface Segregation Principle (LSP)

  • 在设计中避免不必要的依赖
  • 软件系统不应该依赖其不直接使用的组件

子类不应该被强制实现用不到的接口,换句话说,大量的小接口是要优于的少量的大接口的。

接口隔离原则可帮助我们防止违反单一职责原则和关注点分离原则。

假设您有两个实体:矩形和圆形。您已经在业务层中使用这些实体来计算它们的面积,并且效果很好,但是现在我们基础架构层需要增加一个序列化的方法。我们可以通过向Shape接口添加一个额外的方法来解决该问题:


interface Shape {
    area(): number;
    serialize(): string;
}

class Rectangle implements Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }

    public serialize() {
        return JSON.stringify(this);
    }
}

class Circle implements  Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }

    public serialize() {
        return JSON.stringify(this);
    }

}
复制代码

但是很明显我们的getArea方法并不需要序列化方法。


function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
复制代码

而基础架构层使用序列化方法的地方也不需要getArea()方法。


// ...
return rectangle.serialize();
复制代码

上述解决方案的问题在于,向Shape接口添加名为serialize的方法违反了SoC原则和单一职责原则。 Shape是业务问题,序列化方法是基础架构层问题。我们不应该将这两个问题混在同一个接口。

接口隔离原理告诉我们,大量的小接口是要优于的少量的大接口,这意味着我们应该拆分接口:


interface RectangleInterface {
    width: number;
    height: number;
}

interface CircleInterface {
    radius: number;
}

interface Shape {
    area(): number;
}

interface Serializable {
    serialize(): string;
}
复制代码

使用新的接口,我们在业务层中完全隔离了基础架构层中的方法:


class Rectangle implements RectangleInterface, Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }
}

class Circle implements CircleInterface, Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }
}

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}
复制代码

在基础架构层,我们可以用一组新的序列化方法处理的实体:


class RectangleDTO implements RectangleInterface, Serializable {

    public width: number;
    public height: number;

    public serialize() {
        return JSON.stringify(this);
    }
}

class CircleDTO implements CircleInterface, Serializable {

    public radius: number;

    public serialize() {
        return JSON.stringify(this);
    }
}
复制代码

使用多个接口而不是一个通用接口帮助我们避免违反SoC原则(业务层与基础设施层隔离互不干扰)和单一职责原则(每一层🈯只负责当层的逻辑)。

也许有人说RectangleDTO和Rectangle几乎相同,它们违反了“不要重复自己”(DRY)的原则。我认为情况并非如此,因为尽管它们看起来相同,但它们有两个不同的关注点。当两段代码看起来相似时,并不总是意味着它们是同一回事。

而且,即使它们违反了DRY原则,我们也必须在违反DRY原则或SOLID原则之间进行选择。我相信DRY原则没有SOLID原则重要,因此,在这种情况下,我会选择DRY原则。

依赖倒置原则 Dependency Inversion Principle (DIP)

  • 高层模块不应该依赖底层模块。这两个都应该依赖于抽象
  • 抽象不依赖于细节。细节需要依赖抽象。

依赖倒置原则告诉我们,我们应该始终对接口而不是类具有依赖关系。重要的是要记得依赖倒置和依赖注入不是一回事。

虽然依赖倒置原则在SOLID中的使用最后的字母D来表示,但它是SOLID中最重要的原理。如果没有依赖倒置原则,大多数其他SOLID原则都是不存在的。 如果我们回顾所有先前解释的原则,我们将意识到接口的使用是每个原则中最基本的元素之一:

下图来自知乎

如果用不支持接口的编程语言或不支持多态性的编程范例来实现SOLID原则是很不自然的。例如,在JavaScript ES5甚至ES6中实现SOLID原理感觉很不自然。但是,在TypeScript中,它感觉很自然。

参考资料

感兴趣的可以关注我的关注号:

关注下面的标签,发现更多相似文章
评论