用命令模式指挥你的Ruby代码

68 阅读9分钟

Ruby简化了许多经典的设计模式,这已经不是什么秘密了。在Java中可能需要30行的东西,在Ruby中往往只需要一行。但是学习一个设计模式不仅仅是阅读一些关于Widgets和AbstractFlyweightDecoratorFactory类的伪代码。命令模式是一个抽象概念,对于大多数有经验的Ruby程序员来说都很熟悉,但本文的目的是探讨一些关于采用该模式的次要考虑因素。

在我们深入探讨之前,定义一下Command模式的含义会很有帮助。 正如最初描述的那样,Command模式是一种将请求表示为一个对象的方式,并将调用行为与执行行为分开,通常是在GUI应用程序的上下文中。调用者将任何必要的数据传递给命令,而命令则负责执行一些逻辑。我们发现在标准的Rails应用中采用这种模式非常成功。

一个对象为捆绑相关的状态和逻辑提供了一个方便的边界。假设我有一个制造汽车的命令:

class BuildCar
    attr_reader :engine_type, :transmission_type, :factory

    def initialize(engine_type:, transmission_type:, factory:)
        @engine_type = engine_type
        @transmission_type = transmission_type
        @factory = factory
    end

    def execute
        validate!
        engine = FindEngine.new(type: engine_type).execute
        transmission = FindTransmission.new(type: transmission_type).execute
        factory.build(engine: engine, transmission: transmission)
    end

    def validate!
        raise ArgumentError, “Engine type not valid” unless %w[rotary v6].includes?(engine_type)
        raise ArgumentError, “Transmission type not valid” unless %w[at mt].includes?(transmission_type)
    end
end

让我们为Github保存PR审查反馈。重点是,所有这些都是你不一定要在控制器或模型中使用的代码。我们希望我们的控制器专注于协调视图和模型之间的关系。通过缩小控制器需要处理的范围,它们变得更小,更容易维护,也更容易推理。同样的想法也适用于我们的ActiveRecord模型:通过让它们专注于数据库的交互和验证,我们避免用复杂的业务逻辑使它们臃肿。帮助器都被封装在这个对象中,任何你需要保留的中间状态都不会被暴露出来,也不会污染你的命名空间。

Carbon Five is hiring

错误处理和验证

你可能已经注意到了 上面的片段中 的validate! 方法。这是一个简单的方法,可以对我们得到的数据的形状进行断言。对于更有结构性的东西,你可以包括 ActiveModel::Validations

命令对象还为你提供了一个机会,为错误提供一个更一致的接口。你可以把它们包装成你自己的异常层次结构,而不是来自不同库的错误的混合体。

测试

由于命令对象被很好地封装起来,它们是单元测试的理想候选者。所有需要的状态都是通过构造函数传递进来的,所以不需要模拟出球状物。这使得对一个命令的效果的推理变得更加简单。你只需对所有不同的可能输入进行排列组合即可。

命令还为嘲弄提供了一个方便的边界。如果你有一个委托给其他命令对象的命令对象,那么在你的单元测试中,你可以模拟出这些子命令(假设它们本身有足够的测试覆盖)。当处理外部API调用时,这比在HTTP层模拟要方便得多。

例如,假设我们想测试我们的BuildCar 命令。我们并不关心FindEngineFindTransmission 的工作:

describe “BuildCar” do
    let(:engine) { double(“engine”) }
    let(:transmission) { double(“transmission”) }

    before do
        FindEngine.any_instance.stub(:execute).and_return(engine)
        FindTransmission.any_instance.stub(:execute).and_return(transmission)
    end

    it “constructs a car” do
        car = BuildCar.new(engine_type: “rotary”, transmission_type: “mt”).execute
        expect(car.engine.name).to equal(engine.name)
        expect(car.transmission.name).to equal(transmission.name)
    end
end

异步的

命令对象的另一个优点是它们很容易被序列化。运行所需的一切都被传入。如果你有一个需要很长时间才能解决的命令,那么你可以将其序列化并将其发送到后台工作者队列中:

class BuildCarJob < ApplicationJob
    def perform(engine_type:, transmission_type:, factory_type:)
        factory = GetFactory.new(type: factory_type).execute
        BuildCar.new(engine_type: engine_type, transmission_type: transmission_type, factory: factory).execute
    end
end

BuildCarJob.perform_later(engine_type: “rotary”, transmission_type: “mt”)

这里我们遇到了一个问题:我们不想序列化工厂,所以我们必须用一个字符串代表我们想要的工厂。这样构建的好处是,万一我们对工厂的实现发生了变化,我们就不必担心旧版本会被序列化到某个队列中。

这确实增加了复杂性,但你可以获得异步计算的所有好处(可预测的重试行为、解耦系统、更好的扩展特性)。一个好的建议是只传递普通类型,如字符串和数字;试图序列化和反序列化Ruby对象会导致头痛。

撤销

命令模式的一个好处是,它允许你轻松地撤销或回滚你所做的改变。在理论上,你有所有你需要的数据来恢复你所做的改变。但在实践中,撤销一个命令并不像逆转执行步骤那么简单。前提条件可能不同,你想处理错误的方式也可能改变。

撤销建造一辆汽车是没有意义的,所以说我们有一个命令是要增加汽车的里程表:

class IncrementOdometer
    attr_reader :car, :distance

    def initialize(car:, distance:)
        @car = car
        @distance = distance
    end

    def execute
        car.mileage += distance
    end

    def undo
        car.mileage -= distance
    end
end

我打赌车主一定很想有一个撤消的功能!"。这个例子有点牵强,但撤销方法可以删除资源或回滚到早期版本。

最佳实践

封装

你会发现,如果你实行良好的封装,测试、重构和维护会更简单。你的命令对象不应该引用全局状态。把它传递进来。你应该把你的命令对象看作是黑盒子--客户并不关心里面发生了什么。他们只关心进去的是什么,出来的是什么。

组成

命令可以调用其他的命令!这可以是一个强大的抽象,帮助你的命令专注于一个特定的领域和抽象层。子命令可以很容易地被模拟,简化了你的测试案例。

另一个看起来很有吸引力的抽象是继承。由于命令是Ruby类,你可以使用继承来建立一个对象的层次结构。组成与继承在Ruby社区中经常被争论。一般来说,继承给你实现之间更紧密的耦合,意味着更少的代码。但有时松散的耦合会给你空间,让你以后更积极地进行重构。出于这个原因,我们更喜欢组合而不是继承。

短暂的

不要让你的命令对象存在太长时间。它们存在的时间越长,其内部状态就越有可能变得过时。试图将命令对象的状态与全局状态同步会变成一个令人头痛的问题。把命令对象看成是一个一次性的过程。

不要暴露实施细节

你的命令对象的用户不应该需要了解它是如何工作的。这与良好的封装是相辅相成的,但你可能会错过的一个例子是处理API响应。如果你使用法拉第来进行API调用,你不一定想让你的用户处理HTTP状态码或解析响应体。让你的客户端返回用户友好的响应对象,这对你试图表达的领域是有意义的。

同样地,包裹异常可能也是有意义的。你可能会使用两三个库,它们都有自己的超时错误的实现,但实际上你的用户只需要知道它是可以在以后重试的。

控制台友好

命令很容易复制和粘贴到终端或控制台。当你试图调试一个问题或探索一个API时,维护一个经常使用的命令文件是很有帮助的。与直接调用HTTP相比,一个命令可以提供一个更简单的界面。

关键字参数

由于命令可以不断重构,你可能会发现到处使用关键字参数是很有用的。它们有很多优点:它们是自我记录的,它们与位置无关,而且它们为提供默认值提供了更多的灵活性。


缺点

这并不是说命令模式是万能的。它们使一些事情变得更容易,但其他事情变得更难或更复杂。

文件臃肿

如果你每个命令都有一个文件,那么你会很快发现自己淹没在额外的文件中。这就是高度因子化代码的代价之一。你可以使用模块和子目录来管理复杂性,以及一些更高级别的模式,如 Mediator ,为用户提供一个更简单的界面。

冗余度

有时候,你可能会觉得一个命令并没有做什么。当它只是一个围绕着API客户端的薄薄的包装时,费力地使用一个命令可能会觉得是多余的。但它仍然提供了一些价值:一个额外的间接层可以让你轻松地换掉其他实现。而且你可以提供一个统一的接口来处理响应和异常。

这可能有助于设计出更多有意的命令:与其说是一个通用的UpdateAppointment命令,不如说是RescheduleAppointment或CancelAppointment?这允许你做更强的验证并减少可能出错的范围。

正交性

有时你需要以一种与你所创建的层次结构正交的方式来使用一个命令。这方面的一个很好的例子就是并发性。假设我们有一个命令来建造几辆汽车:

class BuildCarFleet
    attr_reader :engine_type, :transmission_type, :quantity

    def initialize(engine_type:, transmission_type:, quantity:)
        @engine_type = engine_type
        @transmission_type = transmission_type
        @quantity = quantity
    end

    def execute
        quantity.times do
            BuildCar.new(engine_type: engine_type, transmission_type: transmission_type).execute
        end
    end
end

现在 BuildCar 下面埋藏了大量的冗余 。没有必要重复调用 FindEngineFindTransmission;我们可以在命令的开始就调用它们一次。此外,假设 BuildCar 进行了一次远程调用,并且是IO绑定的。如果我们可以复用它,那么连续调用它就没有意义了:

class BuildCarFleetOptimized
    attr_reader :engine_type, :transmission_type, :quantity

    def initialize(engine_type:, transmission_type:, quantity:, factory:)
        @engine_type = engine_type
        @transmission_type = transmission_type
        @quantity = quantity
        @factory = factory
    end

    def execute
        validate!
        engine = FindEngine.new(type: engine_type).execute
        transmission = FindTransmission.new(type: transmission_type).execute
        promises = quantity.times.map do
            Concurrent::Promises.future do
                factory.build(engine: engine, transmission: transmission)
            end
        end
        Concurrent::Promises.zip(*promises)
    end

    def validate!
        raise ArgumentError, “Engine type not valid” unless %w[rotary v6].includes?(engine_type)
        raise ArgumentError, “Transmission type not valid” unless %w[at mt].includes?(transmission_type)
    end
end

这个版本只调用 FindEngineFindTransmission 一次,而且它利用了Concurrent Ruby来并发地构建汽车。但由于没有使用 BuildCar,我们引入了大量的代码重复。正如通常的情况一样,在可维护性和性能之间存在着权衡。这个版本最大限度地减少了API调用的数量,并且可以将工作并行化,但它更复杂,重用的代码更少,并且更难重构。

总结

希望这能让你对这种模式能做什么和不能做什么有一些概念。你甚至可以把它和其他一些常见的模式结合起来: 策略 可以让你交换实现, 装饰器 可以让你以模块化的方式添加功能,而 责任链 可以让你解耦和分散逻辑。