维护任何应用程序、框架或系统的一个重要部分是处理历史代码。无论一个系统的架构有多好,历史遗留问题总是会随着时间的推移而被建立起来——这可能是因为底层SDK的变化,因为功能集的扩展,或者仅仅是因为团队中没有人真正知道某个特定部分是如何工作的。
我非常赞成在现有基础上持续地处理历史代码,而不是等待一个系统变得纠缠不清,以至于必须完全重写。虽然完全重写听起来很诱人(经典的 "我们从头开始重写"),但根据我的经验,它们很少值得这样做。通常情况下,最终发生的情况是,现有的错误和问题只是被新的问题所取代😅。
与其承受从头开始完全重写一个巨大系统的所有压力、风险和痛苦,不如让我们看看我在处理历史代码时通常使用的技术——它可以让你逐步替换一个有问题的系统,而不是一次性完成。
逐步替换流程
1. 选择你的目标
我们要做的第一件事是选择我们应用程序中需要重构的部分。它可以是一个经常导致问题和bug的子系统,它也许使实现新功能比正常情况下更难,或者是团队中大多数人都不敢碰的东西,因为它太复杂了。
比方说,在我们的应用程序中,有一个这样的子系统是我们用来处理模型的。它由一个ModelStorage
类组成,该类又有许多不同的依赖关系和类型,它用于序列化、缓存和文件系统访问等方面。
不是选择整个系统作为我们的目标,并从重写ModelStorage
开始,而是我们将尝试找出一个我们可以单独替换的类(也就是说,它本身没有很多的依赖性)。举个例子,假设我们选择一个Database
类,ModelStorage
用它来和我们选择的数据库交互。
2. 标记 API
确切地说,我们的目标类在引擎盖下如何工作并不是特别重要。更重要的是通过查看其面向公众的 API 来定义它应该做什么。然后,我们将列出所有没有标记为private
或fileprivate
的方法和属性。对于我们的数据库类,我们得出以下结果:
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
——它有一个方法来对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
,上面的测试暂时会失败。所以下一步就是通过编写新的实现,使其与历史的实现兼容,从而使测试通过。
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 Sundell 的 Replacing legacy code using Swift protocols
网友评论