有些文章不一定是为了上首页投稿

利用Swift协议替换历史遗留的代码

2022-03-26  本文已影响0人  韦弦Zhy

维护任何应用程序、框架或系统的一个重要部分是处理历史代码。无论一个系统的架构有多好,历史遗留问题总是会随着时间的推移而被建立起来——这可能是因为底层SDK的变化,因为功能集的扩展,或者仅仅是因为团队中没有人真正知道某个特定部分是如何工作的。

我非常赞成在现有基础上持续地处理历史代码,而不是等待一个系统变得纠缠不清,以至于必须完全重写。虽然完全重写听起来很诱人(经典的 "我们从头开始重写"),但根据我的经验,它们很少值得这样做。通常情况下,最终发生的情况是,现有的错误和问题只是被新的问题所取代😅。

与其承受从头开始完全重写一个巨大系统的所有压力、风险和痛苦,不如让我们看看我在处理历史代码时通常使用的技术——它可以让你逐步替换一个有问题的系统,而不是一次性完成。

逐步替换流程

1. 选择你的目标

我们要做的第一件事是选择我们应用程序中需要重构的部分。它可以是一个经常导致问题和bug的子系统,它也许使实现新功能比正常情况下更难,或者是团队中大多数人都不敢碰的东西,因为它太复杂了。

比方说,在我们的应用程序中,有一个这样的子系统是我们用来处理模型的。它由一个ModelStorage类组成,该类又有许多不同的依赖关系和类型,它用于序列化、缓存和文件系统访问等方面。

不是选择整个系统作为我们的目标,并从重写ModelStorage开始,而是我们将尝试找出一个我们可以单独替换的类(也就是说,它本身没有很多的依赖性)。举个例子,假设我们选择一个Database类,ModelStorage用它来和我们选择的数据库交互。

2. 标记 API

确切地说,我们的目标类在引擎盖下如何工作并不是特别重要。更重要的是通过查看其面向公众的 API 来定义它应该做什么。然后,我们将列出所有没有标记为privatefileprivate的方法和属性。对于我们的数据库类,我们得出以下结果:

func saveObject<O: Saveable>(_ object: O, forKey key: String) throws
func loadObject<O: Saveable>(forKey key: String) -> O?

3. 提取到一个协议中

接下来,我们要把我们的目标类的 API 提取出来,并将其提取为一个协议。这将使我们以后能够对同一个 API 有多个实现,这反过来又使我们能够用一个新的目标类来反复地替换这个目标类。

protocol Database: class {
    func saveObject<O: Saveable>(_ object: O, forKey key: String) throws
    func loadObject<O: Saveable>(forKey key: String) -> O?
}

关于上述内容有两点需要注意;首先是我们在协议中加入了类的约束。这是为了使我们能够继续做一些事情,比如保持对类型的弱引用,以及使用其他只针对类的功能,比如标识对象的功能

其次,我们用与目标类完全相同的名字来命名我们的协议。这最初会引起一些编译器错误,但以后会使替换过程变得简单得多——特别是当我们的目标类被用于我们应用程序的许多不同部分时。

4. 重命名目标

是时候摆脱那些编译器错误了。首先,让我们重命名我们的目标类,并明确地将其标记为遗留问题。我通常的做法是简单地在类名前加上 "Legacy"--所以我们的数据库类将变成LegacyDatabase

一旦你执行该重命名并构建你的项目,你仍然会留下一些编译器错误。因为Database现在是一个协议,它不能被实例化,所以你会得到这样的错误。

'Database' cannot be constructed because it has no accessible initializers

要解决这个问题,在你的整个项目中进行查找和替换,用LegacyDatabase(替换Database(。 你的项目现在应该重新像正常一样构建👍。

5. 添加一个新的类

现在我们有一个协议定义了我们的目标类的预期 API,并且我们已经将遗留的实现移到了一个遗留类中——我们可以开始替换它了。为了做到这一点,我们将创建一个名为NewDatabase的新类,它将遵循Database协议:

class NewDatabase: Database {
    func saveObject<O: Saveable>(_ object: O, forKey key: String) throws {
        // Leave empty for now
    }

    func loadObject<O: Saveable>(forKey key: String) -> O? {
        // Leave empty for now
        return nil
    }
}

6. 编写迁移测试

在我们开始用闪亮的新代码实现我们的替换类之前,让我们退一步,设置一个测试案例,以帮助我们确保从遗留类迁移到新类的过程顺利进行。

所有重构的一个大风险是,你最终会遗漏 API 应该如何工作的一些细节,从而导致bug和回归。虽然测试不会消除所有这些风险,但设置测试,同时针对我们的历史和新的实现运行,肯定会使这个过程更加稳健。

让我们先创建一个测试用例——DatabaseMigrationTests——它有一个方法来对LegacyDatabaseNewDatabase进行特定的测试:

class DatabaseMigrationTests: XCTestCase {
    func performTest(using closure: (Database) throws -> Void) rethrows {
        try closure(LegacyDatabase())
        try closure(NewDatabase())
    }
}

然后,让我们写一个测试来验证我们的API是否像预期的那样工作,无论使用哪种实现:

func testSavingAndLoadingObject() throws {
    try performTest { database in
        let object = User(id: 123, name: "John")
        try database.saveObject(object, forKey: "key")

        let loadedObject: User? = database.loadObject(forKey: "key")
        XCTAssertEqual(object, loadedObject)
    }
}

由于我们还没有实现NewDatabase,上面的测试暂时会失败。所以下一步就是通过编写新的实现,使其与历史的实现兼容,从而使测试通过。

7. 编写新的实现方案

由于NewDatabase是一个全新的实现,同时仍然能够在我们的整个应用中使用——就像我们之前的应用一样——我们可以自由地以任何方式编写它。我们可以使用依赖注入等技术,甚至可以在内部开始使用一些新的框架。

作为一个例子,让我们用一个使用存储在文件系统上的 JSON 序列化对象的实现来填充NewDatabase:

import Files
import Unbox
import Wrap

class NewDatabase: Database {
    private let folder: Folder

    init(folder: Folder) {
        self.folder = folder
    }

    func saveObject<O: Saveable>(_ object: O, forKey key: String) throws {
        let json = try wrap(object) as Data
        let fileName = O.fileName(forKey: key)
        try folder.createFile(named: fileName, contents: json)
    }

    func loadObject<O: Saveable>(forKey key: String) -> O? {
        let fileName = O.fileName(forKey: key)
        let json = try? folder.file(named: fileName).read()
        return json.flatMap { try? unbox(data: $0) }
    }
}

8. 替换历史的实现

现在我们有了一个新的实现,我们运行我们的迁移测试,以确保它的工作方式和历史遗留的一样。一旦所有测试通过,我们就可以用NewDatabase替换LegacyDatabase

我们将在整个项目中进行查找和替换,用NewDatabase(替换所有出现的LegacyDatabase(。 我们还必须在所有地方传递folder:参数。一旦完成,我们将运行我们应用程序的所有测试,进行手动QA(例如,将这个版本发送给我们的beta测试者),以确保一切运行良好。

9. 移除协议

一旦我们确信我们的新实现和旧的实现一样好用,我们就可以安全地把NewDatabase变成我们唯一的实现。为了做到这一点,我们将NewDatabase重命名为Database,并删除名为Database的协议。

我们必须做最后一次查找和替换,用简单的Database(替换所有出现的NewDatabase(,现在我们的项目中应该不再有任何对NewDatabase的引用。

10. 最后一步

我们几乎完成了! 剩下的就是最后一步了,要么删除我们的迁移测试,要么为我们的新实现重构适当的单元测试(取决于我们的原始数据库类是否有单元测试)。

如果你想保留它们,最简单的方法是将测试用例重命名为DatabaseTests,并简单地在performTest中调用一次闭包,像这样:

class DatabaseTests: XCTestCase {
    func performTest(using closure: (Database) throws -> Void) rethrows {
        try closure(Database(folder: .temporary))
    }
}

这样,你就不必重写或改变任何历史的测试方法👌。

最后,我们可以从我们的项目中删除LegacyDatabase——我们已经成功地用一个闪亮的新类取代了一个历史遗留类——所有这些对我们应用程序的其他部分的影响和风险都是最小的。现在我们可以继续使用这种技术,逐个类地替换ModelStorage系统的其他部分。

小结

尽管这种技术很难成为重构和替换遗留代码的银弹,但我认为这样做(或一些类似的方式)确实可以帮助减少做这种工作时通常涉及的风险。

在开始重构一个大系统之前,确实需要多做一些前期规划,但我仍然认为像这样迭代地进行重构是值得的,而不是一次就把所有东西都重写。

你是怎么想的?你最喜欢的重构技术是什么,你觉得用这种方式替换历史遗留代码有用吗?

感谢您的阅读 🚀

译自 John SundellReplacing legacy code using Swift protocols

上一篇下一篇

猜你喜欢

热点阅读