[译] Replacing legacy code using Swift protocols

在维护一个 app、框架、系统的过程中,如何维护老代码是非常重要的一件事。无论一个系统的框架前期搭得有多好,随着时间的慢慢推移,这些老代码就会慢慢变得难以维护。这可能是因为底层 SDK 的变动,也有可能是功能的增多,或者仅仅是因为在开发团队中,根本没有人知道这部分代码究竟是如何工作的。

我是一直推崇不断重构老代码的,而不是非等到整个系统变得混乱不堪,再去完全重写。虽然完全重写代码这种方式听着非常吸引人。但在我看来一点都不值得。这样做的最终结果只不过是老的 bug 和问题被替换成了新的 bug 而已。

与其忍受将一个复杂系统从头开始完全重写带来的压力、风险、痛苦,我们还是来尝试一个我经常用的一个方法吧。这个方法可以让你对一个复杂系统中的每个类逐个进行替换,而并不需要一次性全部重写。

1. 确定目标类

首先得确定 app 中的哪一部份代码需要进行重构。可以是经常引起 bug 的部分,或是每次添加新功能都显得非常困难的部分,又或者是代码太复杂了,以至于让开发团队中大部分人不愿意接触的部分。

先假定在 app 中可能存在上述问题的部分是数据持久层的代码吧。其中会包含 ModelStorage 类,类里面会包含很多依赖和类型,用于序列化、缓存、文件系统访问等功能。

我们并不会选择这整个复杂系统作为目标。而是会先找到其中具体某个类,独立地进行替换(一个单独类并不会具有太多的依赖)。举个例子,选择其中的 Database 类。

2. 明确 API

可以这么说,目标类的具体实现细节并不重要。更重要的是要定义好公开的 API。接下来,我会列出所有不会被标记为 privatefileprivate 的方法和属性。对于之前选择的 Database 类,归纳如下:

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?
}

关于上面的代码,有两点需要特别说明一下。首先是对 protocol 增加 class 的约束,这样做的原因是可以实现对类型的 weak 引用,或者使用类相关的特性。

其次,将 protocol 名称命名为与目标类相同。虽然现在这样子做会造成一些编译错误,但当目标类在 app 中使用非常多时,会让替换的工作变得更简单一些。

4. 重命名目标类

是时候摆脱那些恼人的编译错误啦。第一步,重命名目标类,做一个标记。我的一般做法是,给类添加前缀 Legacy。好啦,现在 Database 类变成了 LegacyDatabase 类。

完成重命名后,构建工程,你会发现仍旧存在着编译错误。这是因为 Database 现在是一个 protocol 啦,而 protocol 是不能被实例化的。会得到如下的错误信息:

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

为了解决上面的错误,在工程全局搜索 Database,并替换为 LegacyDatabase。这下工程就可以正常构建啦。

5. 添加一个新类

现在有了协议,协议里定义了目标类的 API。下面开始替换工作。首先新建 NewDatabase 类,并让该类遵守 Database protocol

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 产生。尽管测试并不能保证所有 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,上面的测试还不能通过。所以下一步的目标就是,以一种与老代码相兼容的方式,编写 NewDatabase 中的实现,进而让测试通过。

7. 完成新类中的实现

因为 NewDatabase 类是完全重写实现的,我们可以非常自由地写实现代码。可以使用诸如依赖注入,抑或在内部使用一些新的库。

比如说,在 NewDatabase 类中,在文件系统中存储 JSON 序列化对象。

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 参数。完成之后,再重新跑一遍所有测试代码,然后真机测试一下,确保所有代码工作正常。

9. 删除协议

当确保新代码可以正常工作后,就可以安全删除掉 LegacyDatabase 类的实现了。首先把 NewDatabase 类重命名为 Database 类,并删除 Database 协议。

10. 收尾

离成功还差最后一步啦!删除迁移测试代码,或者将其重构为合适的单元测试代码(这取决于最原始的 Database 类是否有单元测试代码)。

如果想保留这部分测试代码,最简单的方法就是将其重命名为 DatabaseTests,然后在 performTest 方法中执行闭包,如下:

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

如果采用上面的方法,就不必重写或改变实际的测试方法了。

最后,从工程中删除 LegacyDatabase 相关代码,也就成功完成了从老类到新类的替换工作。这种方法对 app 中的其它部分影响最小,风险最低。接下来,还可以重复使用这种方法,在 ModelStorage system 中逐个替换掉其他的类。

总结

虽然上面介绍的这种方法,可能并不是重构和替换老代码的终极绝招(silver bullet),可我个人认为这种方法确实能够在替换过程中减少一些潜在风险。

在开始重构整个复杂系统之前,可能需要花费一些时间进行计划,但都是非常值得去做的。不管怎么说,这总比一下子要重写所有代码要好得多。

你有什么思考和想法吗?你有什么特别的重构方法?你认为本文中介绍的方法是否有用?让我知道吧,有任何问题或者评论,可以在 twitter 上找我 @johnsundell

最后的最后,如果你还想再多阅读几篇我的文章,请阅读 《Best of the first 6 months of Swift by Sundell》,在这篇文章里,我列出了我之前写的26篇非常精彩的文章。

谢谢阅读! 🚀

原文链接

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,186评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,858评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,620评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,888评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,009评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,149评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,204评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,956评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,385评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,698评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,863评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,544评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,185评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,899评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,141评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,684评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,750评论 2 351

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,900评论 25 707
  • 和好友云云约了好久:啥事有空一起去街上灌香肠,今天中午,云云突然发来信息:今天下午有时间,你忙吗?一起上街...
    浪迹天涯之歌阅读 305评论 4 4
  • 准确的说是两只大猫和一只小猫:猫爸—大公猫,猫妈—大母猫和她们的孩子—小母猫。 三只性格迥然不同: 1. 猫爸爸是...
    Anna亚男阅读 537评论 2 4
  • 1.首先看下问题情况: 2.解决办法: 将该证书拖动到登录,即可生成秘钥 3.stackoverflow描述: s...
    胖次有毒zZ阅读 6,555评论 3 1