这是用户在 2024-12-2 14:53 为 https://jobandtalent.engineering/ios-architecture-separating-logic-from-effects-7629cb763352 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?

Get unlimited access to the best of Medium for less than $1/week.

iOS Architecture: Separating logic from effects
iOS 架构:将逻辑与效果分离

Luis Recuenco
Job&Talent Engineering
21 min readSep 11, 2018

This article is the second in a three-part series. You can find the first one here and the third, and final one here.
本文是三部分系列中的第二篇。第一篇可在此处找到,第三篇也是最后一篇可在此处找到。

A year with a State Container based approach
基于状态容器的年度实践

It’s been almost a year since I last wrote about the new architecture we implemented in the iOS team at Jobandtalent. It was an architecture that tried to take advantage of some of the best features of Swift (value types, generics and sum types to name a few), while embracing some unidirectional data flow fundamentals.

The results have been incredibly positive so far. Our code ended up being much simpler and easy to reason about than it used to be. We think about state modelling in a totally different way, avoiding code with the two main problems I explained in detail in my previous article:
距离我上次撰写关于我们在 Jobandtalent iOS 团队中实施的新架构已近一年。该架构试图利用 Swift 的一些最佳特性(如值类型、泛型和和类型等),同时采纳了单向数据流的基本原则。

  • The problem about multiple, scattered, out-of-sync outputs.
  • The problem about exclusive states.
    迄今为止,成果极为显著。我们的代码最终变得比以往更加简洁且易于理解。我们在状态建模方面有了全新的思考方式,避免了我在前一篇文章中详细阐述的两个主要问题: 1. 多重、分散、不同步的输出问题。 2. 互斥状态的问题。

This was possible due to a single, unified structure modelling the state of a specific domain and due to the fact that Swift has a rich type system with Algebraic Data Types (ADTs from now on) that made us model our domain in a better way.
这之所以成为可能,是因为采用了单一、统一的结构来建模特定领域的状态,并且得益于 Swift 丰富的类型系统,尤其是代数数据类型(ADT),使我们能够更优地建模领域。

Still, we had a big underlying problem. A problem that was hard to foresee at first. And that’s the problem of coupled logic and effects. This article will describe the evolution of our previous architecture into a better approach by moving the logic to the value layer (pure part) and making the reference types layer (impure part) handle effects and coordination between objects.
然而,我们面临着一个深层次的大问题,起初难以预见。那就是逻辑与效果耦合的问题。本文将阐述我们如何将之前的架构演进为更佳方案,通过将逻辑移至值层(纯函数部分),并让引用类型层(非纯部分)负责处理效果及对象间的协调。

But before I get into that, I’d like to establish some fundamentals and concerns about the State Container Architecture.
但在深入探讨之前,我想先奠定一些关于状态容器架构的基本概念和关注点。

Fundamentals 基础知识

What sum types are all about
什么是和类型

Many iOS and Swift developers aren’t really familiar with the concept of sum types (also called tagged unions, variant types or more formally, coproduct types). Swift exposes us to the concept of enum with associated values, and that’s perfectly fine. The real problem comes when we cannot explain what the real advantage of that enum with associated values is. And when we don’t understand what’s the real purpose of the type, it’s very difficult for us to use it appropriately. So, here it is:
许多 iOS 和 Swift 开发者对和类型(也称为标记联合、变体类型,更正式地说是余积类型)的概念并不十分熟悉。Swift 通过带有关联值的枚举向我们展示了这一概念,这完全没问题。真正的问题在于,当我们无法解释带有关联值的枚举的真正优势时。而当我们不理解类型的真正用途时,就很难恰当地使用它。所以,这就是关键:

The Sum Type makes illegal states impossible
和类型使得非法状态变得不可能

It’s that simple. 就这么简单。

I guess the next question is what’s an illegal state? For that, let’s imagine a simple callback function like this (Data?, Error?)-> Void. These types of callbacks were extremely common back in the old Objective-C days. Which are the possible values of the tuple (product type) (Data?, Error?) ?
我猜下一个问题是,什么是非法状态?为此,让我们想象一个简单的回调函数,就像这样 (Data?, Error?)-> Void 。这种类型的回调在旧的 Objective-C 时代非常常见。那么,元组(积类型) (Data?, Error?) 可能的值有哪些?

(Data, Error) -> Not valid
(Data, nil) -> Valid
(nil, Error) -> Valid
(nil, nil) -> Not valid

As you can see, there’s only two legal states, but, as (Data?, Error?) is a product type, we have exactly 2 * 2 possible combinations for the values inside the type. We usually have to ways of solving this:
如你所见,只有两种合法状态,但由于 (Data?, Error?) 是积类型,我们恰好有 2 * 2 种可能的内部值组合。通常有两种解决方法:

  • Convention: We check whether we have a valid error first. If that’s the case, we don’t care about the data.
    约定:我们先检查是否存在有效错误。如果是这种情况,我们就不关心数据了。
  • Testing: Making sure none of the code paths produce the invalid states.
    测试:确保没有任何代码路径产生无效状态。

I don’t want convention for this. I don’t even want tests for this. I want a rich algebraic type system that prevents me from doing this in the first place. There’s no better way to protect you from illegal code that the fact that you cannot write it.
我不希望采用约定方式。我甚至不希望为此编写测试。我想要一个丰富的代数类型系统,从一开始就阻止我这样做。没有什么比“你无法编写非法代码”更能保护你免受非法代码侵害的了。

So, what’s the proper way to model this behaviour? You guessed it, a sum type:
那么,如何正确地建模这种行为呢?你猜对了,就是和类型:

enum Result<T, E: Error> {
case success(T)
case failure(E)
}

There is simply no way of writing the previous valid data and valid error case. Swift is preventing us from making illegal states possible. And that’s the real magic of sum types. Once you start modelling your state using sum types, it’s really hard to go back to languages without them. It’s the first thing I miss when I have to touch old Objective-C code.
根本无法编写前述的有效数据和有效错误情况。Swift 正阻止我们制造非法状态的可能性。而这正是和类型的真正魔力所在。一旦你开始使用和类型来建模你的状态,就很难再回到没有这些特性的语言中去。这是我接触旧版 Objective-C 代码时首先怀念的东西。

And in case you want to make the error case an impossible state, that’s what bottom types are for. In Swift we have Never, but you can easily create your own, more semantic version by simply having an empty enum.

enum NoError: Error {}
let impossible = Result<Data, NoError>.failure(???) // Cannot create

Sum types and the Rule of Representation
如果你希望将错误情况变为不可能状态,那就是底类型的用武之地。在 Swift 中我们有 `Never`,但你也可以通过创建一个空枚举轻松构建更具语义的版本。

I’ve always loved Unix philosophy. Small and simple programs that make one thing well and that are able to compose and cooperate nicely to solve bigger problems (that’s what FP is all about with functions or OOP with objects…). But there’s a rule that really made me think the first time, and that’s the Rule of Representation.

Fold knowledge into data so program logic can be stupid and robust
和类型与表示法则 我一直钟爱 Unix 哲学。那些小巧而简单的程序,专注于做好一件事,并且能够很好地组合与协作,以解决更大的问题(这正是函数式编程通过函数或面向对象编程通过对象所追求的……)。但有一条法则真正让我第一次思考,那就是表示法则。 将知识融入数据,使程序逻辑保持简单而健壮。

That’s exactly what proper, rich ADTs make me feel. They hide all the complexity so I don’t need to have all that nasty logic that prevents the illegal states and makes my code complex and hard to reason about.
这正是恰当、丰富的抽象数据类型(ADT)带给我的感受。它们隐藏了所有复杂性,使我无需编写那些防止非法状态并使代码复杂难懂的繁琐逻辑。

By the way, chapter 9 of The Mythical Man-Month stated this already in 1975.
顺便提一下,《人月神话》第 9 章早在 1975 年就阐述了这一点。

Show me your flowchart and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won’t usually need your flowchart; it’ll be obvious
给我看你的流程图,隐藏你的表格,我仍会感到困惑。给我看你的表格,我通常就不再需要你的流程图;一切将显而易见。

Sum types and the Open Closed Principle (OCP)
和开放封闭原则(OCP)

Let’s consider a simple app where we have to draw geometric figures like squares or circles. In a more OOP-ish approach, we would create an interface or base class Figure with a draw method. Then, the implementations Square and Circle would implement that draw method providing the appropriate behaviour. In a more FP-ish approach, we would create a sum type Square | Circle with a method draw. What are the advantages and disadvantages of each approach?
让我们考虑一个简单的应用,其中我们需要绘制几何图形,如正方形或圆形。在更偏向面向对象编程(OOP)的方法中,我们会创建一个接口或基类 Figure ,并定义一个 draw 方法。然后,实现类 SquareCircle 将实现该 draw 方法,提供相应的行为。在更偏向函数式编程(FP)的方法中,我们会创建一个和类型 Square | Circle ,并定义一个 draw 方法。每种方法各有什么优缺点呢?

  • OOP approach: we can easily add new figures by creating new classes that implement the abstraction. Unfortunately, adding new specific operations (behaviours) is difficult, as you’d have to implement the new methods in all the different classes.
    面向对象方法:我们可以通过创建实现抽象的新类轻松添加新图形。然而,添加新的具体操作(行为)则较为困难,因为必须在所有不同类中实现新方法。
  • FP approach: adding figures is more difficult, as you would need to change all the different methods that accept a figure to handle the new case. Fortunately, adding new methods and behaviours is a simple as extending the type creating a new method.
    函数式编程方法:添加图形更为复杂,因为需要修改所有接受图形的不同方法以处理新情况。幸运的是,添加新方法和行为只需扩展类型并创建新方法,非常简单。

The OOP approach complies with OCP. The second doesn’t. Does this mean it’s a bad choice? Well, not all code that complies with OCP is necessarily better code. You might end up in a situation where it’s not so common to add new figures, but more behaviours… But, can we have the best of both worlds? Can we have most of your code comply with OCP while using sum types. The answer is yes. Let’s take a look at a simple example.
面向对象方法符合 OCP 原则,而第二种方法则不然。这是否意味着它是一个糟糕的选择?并非所有符合 OCP 的代码必然是更好的代码。你可能会遇到不太常添加新图形,但更频繁添加行为的情况……那么,我们能否兼得鱼与熊掌?能否让大部分代码符合 OCP 的同时使用和类型?答案是肯定的。让我们来看一个简单的例子。

enum State {
case loading
case loaded(Data)
}
class View {
func render(state: State) {
switch state {
case .loading:
loadingView.isHidden = false
tableView.isHidden = true
case .loaded(let data):
loadingView.isHidden = true
tableView.isHidden = false
tableView.render(with: data)
}
}
}

Now, imagine we add a new error case to the enum. The good news is that the code won’t compile until we handle the error case. The bad news is that there might be a lot of places that need fixing. In order to comply with OCP and avoid the impact that a new case can have in our code, the simplest solution is to abstract the functions that destructure the State type (what we called queries in the previous article).
现在,设想我们在枚举中新增一个 error 情况。好消息是,在我们处理这个错误情况之前,代码将无法编译。坏消息则是,可能有许多地方需要修正。为了遵守 OCP 原则并避免新增情况对代码造成影响,最简单的解决方案是对解构 State 类型(我们在前一篇文章中称之为查询)的函数进行抽象。

extension State {
var data: Data? {
switch self {
case .loading: return nil
case .loaded(let data): return data
}
}
var isLoading: Bool {
switch self {
case .loading: return true
case .loaded: return false
}
}
}
class View {
func render(state: State) {
loadingView.isHidden = !state.isLoading
tableView.isHidden = state.isLoading
tableView.render(with: state.data)
}
}

Now, in case we need to add the error case, we only have to update a few local functions that are the ones used all across the code base where state information is needed. That way, we guarantee that all clients of our State object comply with OCP.
如此一来,若需添加 error 情况,我们只需更新少数几个本地函数,这些函数正是代码库中所有需要状态信息的地方所使用的。这样,我们就能确保所有 State 对象的客户端都符合 OCP 原则。

This way of having client code protected from our state shape not only makes our code more robust for future changes, but it also helps us avoid problems related to view reuse. By modelling your view state as a sum type, your view will be reused for the different states (loading state, loaded state, etc). It’s quite easy to forget setting a subview in any of the cases (you hide a view in the loading state but forget to show it back in the loaded state). Using queries guarantees that we always set the appropriate piece of state for each one of the views that we need to handle.
这种保护客户端代码免受状态结构变化影响的方式,不仅使我们的代码对未来更改更具鲁棒性,还帮助我们避免了与视图重用相关的问题。通过将视图状态建模为和类型,视图将被重用于不同的状态(加载状态、已加载状态等)。很容易忘记在某些情况下设置子视图(例如,在加载状态下隐藏视图,但在已加载状态下忘记重新显示)。使用查询确保我们始终为需要处理的每个视图设置适当的状态片段。

All this is very related to the Expression Problem and the extensibility in two dimensions. In case you want to know more, I recommend Brandon Kase’s talk, Finally Solving the Expression Problem.

What value types are all about
这一切与表达问题和二维扩展性密切相关。如果你想了解更多,我推荐 Brandon Kase 的演讲《最终解决表达问题》。

Value types are all about inert data, immutability, and about equivalence. They don’t base the concept of equality on identity, rather, on their underlying value.
值类型的核心 值类型的核心在于惰性数据、不可变性以及等价性。它们不基于身份来定义相等性,而是基于其内在值。

Swift is the first language that really exposed me to value types. There are a lot of things that make value types great. Their memory is handled in the stack and they lack some of the overhead needed by reference types to handle identity or heap allocation (reference count, etc). But the real big deal is that they are copied on assignment (with optional Copy-On-Write (COW) semantics). What does this mean? This simply means that, by using value types, we can avoid the implicit coupling we have when using reference types (aliasing bugs). Being able to reason about code in a way that we can be sure it cannot be modified by other parts of the code base makes a huge difference. This also makes value types perfect for multithreading environment as there’s no need for synchronization primitives.
Swift 是首个真正让我接触到值类型的编程语言。值类型有许多优点,它们的内存管理在栈上进行,且无需处理引用类型所需的身份识别或堆分配开销(如引用计数等)。但真正关键的是,值类型在赋值时会被复制(可选的写时复制(COW)语义)。这意味着什么?简而言之,通过使用值类型,我们可以避免在使用引用类型时可能出现的隐式耦合问题(别名错误)。能够以一种确保代码不会被代码库其他部分修改的方式来推理代码,这带来了巨大的差异。这也使得值类型非常适合多线程环境,因为无需同步原语。

Swift makes it extremely convenient to handle the immutability of value types via the mutating word. Immutability has great advantages, but it can make our code, and ultimately our architecture, a little bit cumbersome to use. By using mutating, we maintain the immutability semantics and have the convenience of being able to mutate the value type in place by letting Swift create the new value and assign it back to the very same variable. It’s the best of both worlds.
Swift 通过 mutating 关键字极大地简化了处理值类型不可变性的操作。不可变性虽有诸多优势,但有时也会让我们的代码乃至整体架构显得略显繁琐。借助 mutating ,我们既能保持不可变语义,又能在原地修改值类型,由 Swift 自动生成新值并重新赋回同一变量,实现了两全其美。

And finally, value types have enormous advantages in testing. They lead to easy data in, data out testing without further ceremony (no mocks whatsoever).

Not everything is great about value types though. Mutating big value types is not as performant as it should be due to the lack of persistent data structures. But I’m confident we’ll have them in future Swift versions, hopefully.
最后,值类型在测试方面具有巨大优势。它们使得数据输入输出测试变得简单直接,无需任何额外繁文缛节(无需模拟对象)。

Separating logic from effects
将逻辑与效果分离

It all started with testing
然而,值类型并非尽善尽美。由于缺乏持久数据结构,对大型值类型进行修改的性能并不理想。但我相信,未来版本的 Swift 有望解决这一问题,让我们拭目以待。 这一切都始于测试。

I’m a firm believer that testing gives you quite an accurate idea about the quality of your code. But I’m not talking about the number of tests or your test coverage, I’m talking about how easily you can test your code. If your code is simple and easy to test, that’s definitely a symptom of good design. Unfortunately, Swift, and the lack of some reflection capabilities (which most of the mocking libraries depend upon), hinder this task.
我坚信测试能相当准确地反映代码质量。但这里我说的不是测试的数量或覆盖率,而是代码的可测试性。如果你的代码简洁易测,那无疑是良好设计的体现。遗憾的是,Swift 及其缺乏某些反射能力(多数模拟库依赖于此),给这一任务带来了阻碍。

Let’s imagine a simple app where we have a view model, which depends on a service object, which depends on an API client. We want to test the view model in isolation. Let’s also suppose we use dependency injection so we can pass any test doubles we want. Ideally, we would do something like this:

let serviceMock = Mock(Service.self)
let sut = ViewModel(service: serviceMock)
expect(serviceMock, #selector(downloadData))
sut.fetchData()serviceMock.verify()

The mock made it very easy to cut the dependency graph and let us test the outgoing command (service.downloadData) without asserting against the real side effect outcome (the actual state change due to the data download).
设想一个简单的应用,其中有一个视图模型依赖于服务对象,而服务对象又依赖于 API 客户端。我们希望单独测试视图模型。假设我们使用依赖注入,以便传入所需的任何测试替身。理想情况下,我们会这样做:

With Swift, the environment for the test gets much more complicated than it used to be.
模拟使得切断依赖关系图变得非常容易,让我们能够测试输出指令(@0#),而不必断言实际的副作用结果(数据下载导致的真实状态变化)。 然而,在 Swift 中,测试环境变得比以往复杂得多。

class ServiceMock: Service {
private(set) var downloadDataCalled = false

init() {
// Provide all the dependencies the APIClient depends upon
let falseAPIClient = APIClient(…)

super.init(apiClient: falseAPIClient)
}

func downloadData() {
downloadDataCalled = true
}
}
let serviceMock = ServiceMock()
let sut = ViewModel(service: serviceMock)
sut.fetchData()
XCTAssertTrue(serviceMock.downloadDataCalled)

As you can see, we had to subclass Service to create the spy. And the real issue is that, in order to create the mock, we need to supply all the appropriate dependencies. In this case, it’s only APIClient, but it could be much worse. And of course you need to create that false APIClient providing all its dependencies… and so on and so forth.
如你所见,我们不得不继承 Service 来创建这个间谍。真正的问题在于,为了生成这个模拟对象,我们需要提供所有适当的依赖项。这里仅涉及 APIClient ,但情况可能更糟。当然,你还需要创建那个虚假的 APIClient ,并提供其所有依赖项……如此循环往复。

Another problem with the subclass and override approach is that you have to be sure to maintain the invariants of the base class. Forget to call super in any of the overridden methods and prepare yourself to have green tests even if your real code is failing miserably.
子类化并重写方法的另一个问题是,你必须确保维护基类的约束条件。如果在任何重写的方法中忘记调用 super,即使实际代码严重失效,你也可能会得到绿色的测试结果。

This makes it incredibly cumbersome and uncomfortable to test some code. There are some options like creating interfaces for the sole purpose of mocking, but we don’t think this explosion of polymorphic interfaces makes the design any better. There are of course places where it makes a lot of sense to abstract ourselves from the outside world (database implementations for instance) via an interface, which let us design by contract and use techniques like Outside-in TDD. You can start designing with your high level contracts without actually deciding which lower level pieces will fulfil those contracts (you can put off your actual persistence implementation choice for instance). If you want to automate the creation of mocks for this very cases, Sourcery is a great tool for that.
这使得测试某些代码变得极其繁琐和不舒适。虽然有一些选择,比如仅为模拟目的创建接口,但我们认为这种多态接口的爆炸式增长并没有使设计变得更好。当然,在某些情况下,通过接口将自己从外部世界(例如数据库实现)中抽象出来是非常有意义的,这让我们能够按契约设计,并使用诸如从外到内的 TDD 等技术。你可以从高层次的契约开始设计,而不必实际决定哪些低层次的组件将实现这些契约(例如,你可以推迟实际的持久化实现选择)。如果你想自动化为这些特定情况创建模拟对象,Sourcery 是一个很棒的工具。

There’s also the option to subclass NSObject if you prefer, but we’ll stick to pure swift reference types for the rest of the article.

Our current solution is to have a simple composition root approach where we can inject some test doubles for specific parts of the graph without supplying the rest of the dependencies. This eases some of the pain and we might talk about this in a future post.
你也可以选择子类化@0#,但本文其余部分我们将坚持使用纯 Swift 引用类型。 我们目前的解决方案是采用简单的组合根方法,这样我们可以在不提供其余依赖项的情况下,为图中的特定部分注入一些测试替身。这缓解了一些痛点,我们可能会在未来的文章中讨论这一点。

Just to be clear, I’m not advocating the use of mocks in testing, but I do think they are very valuable for some cases. They let us test in isolation and more importantly, they make obvious the bad code when you need to mock six dependencies and override quite a few methods to be able to exercise you subject under test. They tend to be coupled quite often with implementation details, thus, making the tests break when those details are changed, without actually helping catch real issues.
明确一下,我并非提倡在测试中使用模拟对象,但我确实认为在某些情况下它们非常有价值。它们让我们能够独立测试,更重要的是,当你需要模拟六个依赖项并覆盖相当多的方法才能对被测主体进行测试时,它们会明显暴露出糟糕的代码。模拟对象往往与实现细节紧密耦合,因此,当这些细节发生变化时,测试会失败,而实际上并未帮助捕捉到真正的问题。

Also, there are some people that think that not testing against the real thing is not valuable at all. Some of us have had bad experiences with false positives in the past, where the problem happened even if one of the tests should have caught the issue in the first place.

Integration tests are not really the answer to our problems though. Even if they might seem like a good solution to our mocking issues, the number of integration tests you need to cover all the important code paths grows exponentially instead of linearly with isolated tests. To know more, I highly recommend J. B. Rainsberger’s talk: Integrated Tests are a scam.
此外,有些人认为不针对实际对象进行测试毫无价值。我们中的一些人过去曾遭遇过假阳性结果的困扰,即使某个测试本应在第一时间发现问题,但问题依然发生了。 然而,集成测试并非解决我们问题的真正答案。尽管它们看似是解决模拟问题的良方,但覆盖所有重要代码路径所需的集成测试数量会呈指数级增长,而非线性增长,相比之下,独立测试则更为简洁。若想了解更多,我强烈推荐 J. B. Rainsberger 的演讲:《集成测试是骗局》。

Not everything in Swift made testing code harder. In fact, the aforementioned Rule of Representation and the rich ADTs make it possible to skip some tests that are mandatory in some other languages. Testing that some input is in a valid range is something that can be easily modelled by a proper type for instance.
并非 Swift 中的所有特性都让测试代码变得更加困难。事实上,前述的表示法则和丰富的代数数据类型使得我们可以跳过一些在其他语言中必须进行的测试。例如,验证某些输入是否在有效范围内,可以通过适当的类型轻松建模。

I’d like to quote a sentence from Uncle Bob’s article The Dark Path.

Now, ask yourself why these defects happen too often. If your answer is that our languages don’t prevent them, then I strongly suggest that you quit your job and never think about being a programmer again; because defects are never the fault of our languages. Defects are the fault of programmers. It is programmers who create defects — not languages.
我想引用 Uncle Bob 文章《黑暗之路》中的一句话:

And what is it that programmers are supposed to do to prevent defects? I’ll give you one guess. Here are some hints. It’s a verb. It starts with a “T”. Yeah. You got it. TEST!
“现在,问问自己为什么这些缺陷如此频繁地发生。如果你的答案是我们的语言无法阻止它们,那么我强烈建议你辞职,永远不要再考虑成为程序员;因为缺陷从来不是语言的错。缺陷是程序员的错。是程序员制造了缺陷——而不是语言。” 那么,程序员应该做些什么来防止缺陷呢?我给你一个提示。这是一个动词。它以“T”开头。没错,你猜对了。就是:测试(TEST)!

While there’s some truth in that sentence, it’s also true that some languages make it easier to commit more mistakes than others. Tests are definitely the first tool to prevent mistakes in software, but we shouldn’t forget about types and static analysis tools, which make a big number of the typical dynamically typed languages tests obsolete.
虽然那句话有一定道理,但同样真实的是,某些语言确实比其他语言更容易犯更多错误。测试无疑是防止软件错误的首要工具,但我们不应忽视类型系统和静态分析工具,它们使得许多典型动态类型语言的测试变得多余。

So… Interfaces are not the solution. Integrations tests are not the solution either. Which is the solution then? In my experience, code that is not easily tested ends up not being tested at all, at least not all the proper code paths. So, we asked ourselves what we could do to make the most out of our testing suite and be able to test most of the important business rules without suffering on the way. That’s when we discovered the problem about coupled logic and effects.
所以……接口并非解决方案。集成测试也不是。那么,真正的解决方案是什么呢?根据我的经验,难以测试的代码最终往往根本不会被测试,至少不会覆盖所有正确的代码路径。因此,我们问自己,如何才能最大限度地利用我们的测试套件,能够在不遭受痛苦的情况下测试大多数重要的业务规则。正是在那时,我们发现了关于耦合逻辑和效果的问题。

Coupled logic and effects
耦合逻辑与效果

Let’s take a look at the following code.
让我们来看看下面的代码。

class Store {
func fetchData() {
guard !state.isLoading else { return } // logic
service.downloadData() // effect
}
}

That code is really simple, on purpose, but perfectly shows the problem about entangled logic and effects. Let’s imagine we want to test that code. It seems sensible to write these two tests:
这段代码故意设计得非常简单,但完美地展示了逻辑与效果纠缠的问题。假设我们想要测试这段代码。似乎合理地编写以下两个测试:

  • Given state.isLoading = false, service receives the method downloadData when calling fetchData.
    给定 state.isLoading = false ,调用 fetchData. 时, service 接收到方法 downloadData
  • Given state.isLoading = true, service does not receive the method downloadData after calling fetchData.
    给定 state.isLoading = true ,调用 fetchData 后, service 不再接收到方法 downloadData

In order to do this, we need to build the proper infrastructure for the test, that is, setting both the correct state and the service spy to double check that the messages are being sent correctly. This seems like an easy task when we just have two tests, but imagine a real life example, where the logic is much more complex and the effect can be triggered under different circumstances.
为了实现这一点,我们需要为测试构建适当的基础设施,即设置正确的状态和服务间谍,以双重检查消息是否正确发送。当我们只有两个测试时,这似乎是一项简单的任务,但设想一个现实生活中的例子,其中逻辑更为复杂,且效果可能在不同情况下触发。

So, what’s the impact of logic and effects in terms of testing?
那么,逻辑和效果在测试方面有何影响?

  • Logic is responsible for the different code paths. The more logic we have, the more code paths we need to test.
    逻辑负责不同的代码路径。逻辑越多,我们需要测试的代码路径就越多。
  • Effects makes our code difficult to test, as they require testing infrastructure (test doubles).
    副作用使得我们的代码难以测试,因为它们需要测试基础设施(如测试替身)。

What happens when we have logic and effects together? We certainly have the worst of both worlds, a lot of difficult code to test.
当逻辑与副作用混杂在一起时,我们无疑会陷入两难境地,产生大量难以测试的代码。

Let’s try a different approach. How about moving the logic that is responsible for an effect to a value layer that doesn’t trigger the effect but rather, models the forthcoming effect via a value type? That layer will only be responsible for performing the logic and deciding if the effect should be triggered. Then, someone else will be responsible for taking that effect value object and actually performing it (network request, data base access, IO, etc).
让我们尝试一种不同的方法。将负责产生副作用的逻辑移至一个不触发副作用而是通过值类型模拟即将发生的副作用的值层如何?该层仅负责执行逻辑并决定是否应触发副作用。然后,由其他部分负责获取该副作用值对象并实际执行它(如网络请求、数据库访问、IO 操作等)。

struct State {
enum Effect {
case downloadData
}
func fetchData() -> Effect? {
guard !isLoading else { return nil } // logic
return .downloadData // Effect representation
}
}
class EffectHandler {
func handle(effect: Effect) {
switch effect {
case .downloadData:
service.downloadData() // actual effect
}
}
}

This separation is much more profound that it may seem at first. We’ve separated the what from the how. The decision making from the actual decision. The algebra from the interpreter. We’ve made the value, the boundary.
这种分离比初看时更为深刻。我们已将“是什么”与“怎么做”分开,将决策制定与实际决策分开,将代数与解释器分开。我们使值成为了边界。

But let’s go back to our testing example. Now, no matter how complex the logic is, the tests are extremely simple. We only need to create the correct state model (given) and assert that the fetchData (when) result is the correct Effect (then).
但让我们回到测试的例子。现在,无论逻辑多么复杂,测试都变得极其简单。我们只需创建正确的状态模型(给定),并断言 fetchData (当)结果是正确的 Effect (然后)。

XCTAssertEquals(State(isLoading: false).fetchData(), .downloadData)
XCTAssertNil(State(isLoading: true).fetchData())

No need for cumbersome infrastructure with test doubles. No need for difficult tests that grow whenever our logic grows. When our logic grows and our effect has to be triggered under different circumstances, we only need to write new dumb data in, data out tests checking that the Effect is the one I expect.
无需繁琐的基础设施来使用测试替身。无需随着逻辑扩展而增长的复杂测试。当逻辑扩展且效果需在不同情境下触发时,我们只需编写新的“输入数据,输出数据”的简单测试,确保效果符合预期。

Sure, we still have to test that the effect handler actually handles the effects correctly, and that’s where we’d have to use test doubles, but we only have to do that once. Compare that with our previous example where the logic and the effects where together. In fact, as we’ve dramatically reduced the code paths in our imperative shell layer, we could easily use integration testing without worrying about the number of tests we’d need to cover the appropriate code paths. This would avoid the need to create cumbersome test doubles.
当然,我们仍需测试效果处理器是否正确处理了效果,这时会用到测试替身,但只需进行一次。相比之下,之前的例子中逻辑与效果紧密结合。实际上,由于我们大幅减少了命令式外壳层的代码路径,可以轻松采用集成测试,而不必担心需要覆盖的测试数量。这避免了创建繁琐的测试替身的需求。

We have also made our testing code more robust. Now, if our mock implementation breaks, we only break the only test that handles the mock interaction, the collaboration test we have for the class EffectHandler. In our previous implementation, every single test that dealt with the effect would have broken.
我们还增强了测试代码的健壮性。现在,如果模拟实现出现问题,只会影响处理模拟交互的单一测试,即针对 EffectHandler 类的协作测试。而在之前的实现中,涉及效果的每个测试都可能失效。

Our new game has two simple rules:
我们的新游戏有两条简单规则:

  • Move as much logic as possible to the value layer. This is where our business rules are. This is where isolated testing is easy. This is our pure part, our functional core.
    将尽可能多的逻辑移至值层。这里是我们的业务规则所在,也是易于进行隔离测试的地方。这是我们的纯函数核心,功能的核心部分。
  • Model effects as inert value objects that don’t behave and have a thin layer of objects handling effects and coordination on top. This is where we download the data. This is where we save data to disk. This is where we handle I/O. This is where we can afford integration testing. This is our impure part, our imperative shell.

The concept of functional core, imperative shell is not new. In fact, it dates back to 2012, when Gary Bernhardt’s talked about it in Boundaries. This is one of the best talks I’ve ever watched and a lot of the concepts in this article are heavily inspired by it.
将模型效果表现为惰性的值对象,它们不具有行为,并在其上覆盖一层处理效果和协调的对象。这里是我们下载数据的地方,是我们将数据保存到磁盘的地方,是我们处理输入输出的地方。这里我们可以进行集成测试,这是我们的不纯部分,命令式外壳。

From a State Container based approach to an Effects Handler based approach

Architecture changes are always quite difficult for brownfield code bases. You have to be quite careful about the amount of new things you bring to the table. It’s important to analyze all the trade-offs and always try to have something incremental with fewer impact in developer productivity and noticeable improvements. This is easier said than done, of course.
函数式核心、命令式外壳的概念并不新鲜。实际上,它可追溯到 2012 年,当时 Gary Bernhardt 在 Boundaries 中谈及此概念。这是我看过最好的演讲之一,本文中的许多概念深受其启发。 从基于状态容器的方案转向基于效果处理器的方案 架构变更对于遗留代码库来说总是相当困难。你必须非常小心地引入新事物的数量。分析所有权衡利弊并始终尝试实现一些对开发者生产力影响较小且能带来显著改进的增量变化至关重要。当然,说起来容易做起来难。

The State Container based approach we took a year ago was exactly the right trade-off. It contained just the few amount of new things so people didn’t feel overwhelmed by the change. It was an incremental update to the traditional MVVM approach we were already using. Now it’s exactly the right time to bring some new ideas, like modelling events and effects as sum types and have some other deeper constraints to force us have clear separation of logic and effects.

Let’s now talk about the main components of the architecture.

State

The state is arguably the most important part of the architecture, our functional core. The consumption of the state follows the same rules as the previous architecture. We use the concept of queries to make the state clients agnostic of their internals and comply with OCP, like we previously explained. As for mutation, we previously had several mutating methods, that we called commands. Now we have a public and unified way to change the state, the mutating handle(event:) function, which returns an optional Effect. This is one of the most important changes. The State will not only be responsible for deciding the next state based on the event, but also what happens next (effect). This was inspired by how the Elm language handles side effects.
状态可以说是架构中最重要的部分,即我们的功能核心。状态的消耗遵循与先前架构相同的规则。我们采用查询的概念,使状态客户端对其内部机制无感知,并符合 OCP 原则,正如我们之前所解释的那样。至于状态的变更,我们之前有多个称为命令的变更方法。现在,我们有了一个公开且统一的方式来改变状态,即变更 handle(event:) 函数,它返回一个可选的 Effect。这是最重要的变化之一。状态不仅负责根据事件决定下一个状态,还决定接下来会发生什么(效果)。这一设计灵感源自 Elm 语言处理副作用的方式。

The state definition looks like this.
状态的定义如下所示。

protocol State: Equatable {
associatedtype Event
associatedtype Effect
mutating func handle(event: Event) -> Effect?
static var initialState: Self { get }
}

To get a clear understanding, let’s consider a simple app that downloads all the recruitment processes where a candidate can apply. The state will look like this:
为了清晰理解,让我们考虑一个简单的应用程序,它下载候选人可以申请的所有招聘流程。状态将如下所示:

struct ProcessState {
case idle
case loading
case loaded([Process])
case error(String)

static var initialState: ProcessState {
return .idle
}

enum Event: Equatable {
case fetchProcesses
case load(processes: [Process])
case userDidLogOut
}
enum Effect: Equatable {
case downloadProcesses
}
mutating func handle(event: Event) -> Effect? {
switch (self, event) {
case (.loading, .fetchProcesses):
fatalError()
case (_, .fetchProcesses):
self = .loading
return .downloadProcesses

case (_, .load(let processes)):
self = .loaded(processes)
case (_, .userDidLogOut):
self = .idle
}
return nil
}
}

As you can see, when we send the event fetchProcesses, the state changes to the loading state and returns the effect that needs to be triggered next, downloadProcesses. The responsible for handling this effect is the next piece of the puzzle, the effect handler. But before we get to that, let’s talk about events.
如你所见,当我们发送事件 fetchProcesses 时,状态会转变为加载状态,并返回接下来需要触发的效应——downloadProcesses。负责处理这一效应的是下一个关键环节,即效应处理器。但在深入探讨之前,我们先来谈谈事件。

Using a sum type to model messages to objects might not be the most idiomatic thing at first. We are used to modelling messages by using methods, but they have some nice advantages that make them a welcome addition.

  • Common API. Where we previously had different mutating methods to change the state, we now have a single method for that.
    最初,使用和类型来建模对象的消息可能看起来并不那么地道。我们习惯于通过方法来建模消息,但它们具有一些显著优势,使其成为受欢迎的补充。
  • Thanks to the IDE autocompletion, we can easily see all the different events that will produce state change by writing state.handle(event: .|), where | represents the caret.
  • The event represents the future state change. It’s not actually performing any state change. It’s the same idea behind modelling effects as value objects.
    统一的 API。过去我们通过不同的变异方法来改变状态,现在则只需一个方法即可实现。 得益于 IDE 的自动补全功能,我们可以通过输入 @0# 轻松查看所有将引发状态变化的不同事件,其中 @1# 代表光标位置。 事件代表未来的状态变化,它本身并不执行任何状态变更。这与将效应建模为值对象的理念如出一辙。
  • Events can be logged or serialized.
    事件可以被记录或序列化。
  • Events, modelled via sum types, allows us to leverage optics and prisms to compose different states in a simple way.
    通过和类型建模的事件,使我们能够利用光学和棱镜以简单的方式组合不同的状态。

We are not opinionated about how you should structure your state. You might find interesting to have a single source of truth structure (like Redux) or multiple structures (like Flux). Having a single structure makes some things easier, like avoiding inconsistent states across different nodes of your state tree, but it might not be easy to adopt in your current application. Having several stateful pieces is much easier to adopt for brownfield projects, but you need to take care of synchronisation between dependencies around those stateful pieces. Choose wisely.
我们不对您应如何构建状态持有特定观点。您可能会发现拥有单一真实源结构(如 Redux)或多个结构(如 Flux)很有趣。单一结构使得某些事情变得简单,例如避免状态树不同节点间状态不一致,但在您当前的应用中可能不易采用。多个有状态组件对于遗留项目来说更易于采纳,但您需要关注这些有状态组件周围依赖项之间的同步问题。明智选择。

Effect Handler 效果处理器

An effect handler will be responsible for handling the different effects associated with a specific state shape. The definition looks like this.
一个效果处理器将负责处理与特定状态形状相关的不同效果。其定义如下所示。

protocol EffectHandler: class {
associatedtype S: State
func handle(effect: S.Effect) -> Future<S.Event>?
}

As you can see, the effect handler is not only responsible for the effect, but also for providing the optional event that will be sent back to the state.
如你所见,效果处理器不仅负责效果处理,还需提供可选的事件,这些事件将被发送回状态。

The real effect handler will look like this.
实际的效果处理器将如下所示。

class ProcessEffects: EffectHandler {
private let service: ProcessService

init(service: ProcessService) {
self.service = service
}

func handle(effect: ProcessState.Effect) -> Future<ProcessState.Event>? {
switch effect {
case .downloadProcesses:
return service.fetchProcesses()
.map { ProcessState.Event.load(processes: $0) }
}

}
}

Event Source 事件源

State and effects handlers are not enough. We need something listening to our different stateful pieces that eventually need to update the state. That’s what an event source is for. The definition looks like this.
仅靠状态和效果处理器是不够的。我们需要某种机制来监听我们不同的有状态组件,最终这些组件需要更新状态。这就是事件源的作用。其定义如下所示。

protocol EventSource: class {
associatedtype S: State
func configureEventSource(dispatch: @escaping (S.Event) -> Void)
}

Let’s imagine that we have a store handling our current session state and we might need to clear all our processes when the user logs out.
让我们设想一下,我们有一个商店来处理当前的会话状态,当用户注销时,我们可能需要清除所有进程。

class ProcessEvents: EventSource {
private let sessionStore: Store<SessionState>
private var token: SubscriptionToken!

init(sessionStore: Store<SessionState>) {
self.sessionStore = sessionStore
}
func configureEventSource(dispatch: @escaping (ProcessState.Event) -> Void) {
token = sessionStore.subscribe { state in
guard !state.sessionValid else { return }
dispatch(.userDidLogOut)
}

}
}

As you can see, we purposely only provided the function S.Event -> Void in the configureEventSource function. This avoids any access to the underlying state shape, forcing us to send the event with the appropriate data and move the logic to the value layer.
如你所见,我们特意只在 configureEventSource 函数中提供了 S.Event -> Void 函数。这避免了任何对底层状态结构的访问,迫使我们用适当的数据发送事件,并将逻辑转移到值层。

We are almost there… There’s only one missing piece… The piece that glues everything together, the Store.
我们几乎完成了……只差最后一块拼图……将所有部分粘合在一起的那一块,就是 Store。

Store

The store is the missing fundamental piece of the puzzle and it’s responsible for quite a few things:
Store 是拼图中缺失的基础部分,它负责许多事情:

  • It wraps the state value object and allows others to know when it changes (subscription handling).
    它封装了状态值对象,并允许其他对象在其变化时得到通知(处理订阅)。
  • It coordinates state and effect handler to produce the new state and execute the different associated effects.
    它协调状态与效果处理器,生成新状态并执行相关的不同效果。
  • It provides the event source with the function to use to send events.
    它为事件源提供发送事件的函数。
  • It’s the façade for view controllers and other objects to send events to the state.
    它是视图控制器和其他对象向状态发送事件的门面。
  • It handles memory management of effect handler and event source.
    它负责效果处理器和事件源的内存管理。

The implementation is the most complex one. It uses type erasure to handle communication with the effect handler and event source. For simplicity, I will omit all the type erasure and state subscription, which is exactly the same code as in the previous article.
实现是最为复杂的。它采用类型擦除来处理与效果处理器和事件源的通信。为简便起见,我将省略所有类型擦除和状态订阅的代码,这部分与前文完全相同。

final class Store<S: State> {
private let effectHandler: AnyEffectHandler<S>
private let eventSource: AnyEventSource<S>
init<EH: EffectHandler,
ES: EventSource>(effectHandler: EH, eventSource: ES)
where EH.S == S, ES.S == S {
self.effectHandler = AnyEffectHandler.init(effectHandler)
self.eventSource = AnyEventSource.init(eventSource)
self.eventSource.configureEventSource {
[unowned self] in self.dispatch(event: $0)
}

}
var state: S { … } func subscribe(_ block: @escaping (S) -> Void) -> SubscriptionToken { … } @discardableResult
func dispatch(event: S.Event) -> Future<S> {
let effect = state.handle(event: event)
let currentStateFuture = Future(value: self.state)
let effectFuture = effect.flatMap {
effectHandler.handle(effect: $0)
}
let nextEventFuture = effectFuture.flatMap {
self.dispatch(event: $0)
}
return nextEventFuture.map { future in
currentStateFuture.flatMap { _ in future }
} ?? currentStateFuture
}

}

The most interesting part is the dispatch function, which is responsible for the state/effect loop. It processes an incoming event, producing a new state and an optional effect. Next, we handle that effect calling dispatch concurrently until we finish the chain and produce the final state. The use of futures is optional, but it’s handy to know when a specific event has finished.
最有趣的部分是调度函数,它负责状态/效果循环。该函数处理传入的事件,生成新状态和一个可选的效果。接着,我们通过并发调用调度来处理该效果,直至完成链条并产生最终状态。使用期货是可选的,但了解特定事件何时完成非常方便。

Finally, it is meant to be used like this.
最终,它的使用方式如下。

// Create the store with the effects handler and event source
let effects = ProcessEffects(service: …)
let events = ProcessEvents(sessionStore: …)
let store = Store<ProcessState>(effectHandler: effects,
eventSource: events)
// start dispatching events to it
store.dispatch(.fetchProcesses)

The whole picture 整体概览

Let’s finally have a quick recap of all the concepts a some clarifying diagrams to see how everything fits together.
最后,让我们快速回顾所有概念,并辅以一些澄清图示,以观察各部分如何协同工作。

  • State: Your logic lives here. Your data lives here. This is where you have your business rules and decision making, as well as deciding which effects should be triggered after the different state changes and events.
    状态:你的逻辑在此栖息,数据亦然。这里是业务规则与决策制定的场所,也是决定在不同状态变化和事件发生后应触发何种效果的地方。
  • Effect Handler: Responsible for deciding the actions to take for any of the effects.
    效果处理器:负责决定针对任何效果应采取的行动。
  • Event Source: Responsible for listening to different stateful pieces and send events to change the state accordingly.
    事件源:负责监听不同的状态组件,并相应地发送事件以改变状态。
  • Store: The piece that ties everything together. The external façade to the system. The UI talks to it to send events and change state.
    存储:将所有部分紧密结合的核心。它是系统对外的门面,UI 通过它发送事件并改变状态。

Conclusion 结论

I’m quite pragmatic about architectures. As I said in my previous article, there’s no such concept as best architecture.
我对架构持有务实的态度。正如我在前一篇文章中所述,并不存在所谓最佳架构的概念。

The same way some languages make you commit less mistakes than others, I do think that some architectures make you be able to change your code more easily over time. And remember, one architecture is better than other if it lets you change your code more easily over time. Unfortunately, there is no silver bullet for that.
与某些语言相比能减少错误一样,我认为某些架构确实能让你随着时间推移更轻松地修改代码。记住,如果一个架构能让你更轻松地随着时间改变代码,那它就是更好的。遗憾的是,这没有一劳永逸的解决方案。

  • You can choose to have a lot of rules and obstacles about how you should structure your code. This will make developers ship features more slowly and will ultimately be less happy.
  • You can choose to have a more flexible architecture, trusting on the good judgement of people to do the right thing, with some shallow guidelines about the structure of the code base. This will likely lead to inconsistencies and code that it’s not very easy to maintain in the long run.
    你可以选择制定许多规则和障碍,规定代码应如何结构化。这会让开发者发布功能的速度变慢,最终幸福感降低。

The ideal architecture is the one that makes our code evolve healthy over time without redundant overhead.
你也可以选择更灵活的架构,依赖人们的良好判断力去做正确的事,并提供一些浅显的代码库结构指南。这可能会导致不一致性,长期来看代码维护起来并不那么容易。 理想的架构是那种能让我们的代码随着时间健康演进,且没有冗余开销的架构。

Over the lasts years, the iOS community has taken a lot of ideas from other areas. React and Flux popularised the unidirectional approach. Also, functional programming is gaining a lot of traction and popularity lately. Unfortunately, we sometimes fail to consider the imperative context where iOS lives, and try to force ourselves to use some techniques than aren’t simply idiomatic enough for our platform.
过去几年,iOS 社区从其他领域汲取了许多灵感。React 和 Flux 推广了单向数据流的理念。同时,函数式编程近期也获得了极大的关注和流行。然而,我们有时未能充分考虑 iOS 所处的命令式编程环境,试图强行采用一些并不完全适合我们平台的技巧。

And that was our goal from the beginning, create an architecture that was inspired by a lot of different languages or paradigms and feels good to use in iOS without feeling alienated.

This is only the beginning, but I think we’ve made a pretty good start.
这正是我们从一开始的目标:创造一种架构,它受多种语言或范式的启发,在 iOS 上使用时既感觉自然又不显突兀。

We are hiring! 我们正在招聘!

If you want to know more about how is work at Jobandtalent you can read the first impressions of some of our teammates on this blog post or visit our twitter.
若想了解更多关于在 Jobandtalent 工作的体验,您可以阅读这篇博客文章中我们一些团队成员的初次印象,或访问我们的 Twitter。

Thanks Ruben Mendez and Victor Baro for all the valuable feedback while developing the architecture.
这仅仅是一个开始,但我认为我们已经迈出了相当不错的一步。 感谢 Ruben Mendez 和 Victor Baro 在架构开发过程中提供的宝贵反馈。

Job&Talent Engineering
Job&Talent Engineering

Published in Job&Talent Engineering
发布于 Job&Talent 工程团队。

Job&Talent Engineering team blog. The magicians that match people with the right jobs. How do they do it?

Luis Recuenco
Luis Recuenco

Written by Luis Recuenco 由路易斯·雷昆科撰写

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.

More from Luis Recuenco and Job&Talent Engineering
更多来自 Luis Recuenco 与 Job&Talent 工程团队的内容

Recommended from Medium 来自 Medium 的推荐

Lists 列表

See more recommendations