[译] Replacing legacy code using
在维护一个 app
、框架、系统的过程中,如何维护老代码是非常重要的一件事。无论一个系统的框架前期搭得有多好,随着时间的慢慢推移,这些老代码就会慢慢变得难以维护。这可能是因为底层 SDK
的变动,也有可能是功能的增多,或者仅仅是因为在开发团队中,根本没有人知道这部分代码究竟是如何工作的。
我是一直推崇不断重构老代码的,而不是非等到整个系统变得混乱不堪,再去完全重写。虽然完全重写代码这种方式听着非常吸引人。但在我看来一点都不值得。这样做的最终结果只不过是老的 bug
和问题被替换成了新的 bug
而已。
与其忍受将一个复杂系统从头开始完全重写带来的压力、风险、痛苦,我们还是来尝试一个我经常用的一个方法吧。这个方法可以让你对一个复杂系统中的每个类逐个进行替换,而并不需要一次性全部重写。
1. 确定目标类
首先得确定 app
中的哪一部份代码需要进行重构。可以是经常引起 bug
的部分,或是每次添加新功能都显得非常困难的部分,又或者是代码太复杂了,以至于让开发团队中大部分人不愿意接触的部分。
先假定在 app
中可能存在上述问题的部分是数据持久层的代码吧。其中会包含 ModelStorage
类,类里面会包含很多依赖和类型,用于序列化、缓存、文件系统访问等功能。
我们并不会选择这整个复杂系统作为目标。而是会先找到其中具体某个类,独立地进行替换(一个单独类并不会具有太多的依赖)。举个例子,选择其中的 Database
类。
2. 明确 API
可以这么说,目标类的具体实现细节并不重要。更重要的是要定义好公开的 API
。接下来,我会列出所有不会被标记为 private
或 fileprivate
的方法和属性。对于之前选择的 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
,里面会包含对 LegacyDatabase
和 NewDatabase
的测试。
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篇非常精彩的文章。
谢谢阅读! 🚀