草庐IT

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

韦弦Zhy 2023-10-12 原文

维护任何应用程序、框架或系统的一个重要部分是处理历史代码。无论一个系统的架构有多好,历史遗留问题总是会随着时间的推移而被建立起来——这可能是因为底层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

有关利用 Swift 协议替换历史遗留的代码的更多相关文章

  1. ruby - 如何在 buildr 项目中使用 Ruby 代码? - 2

    如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby​​

  2. ruby-on-rails - Rails 源代码 : initialize hash in a weird way? - 2

    在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has

  3. ruby 正则表达式 - 如何替换字符串中匹配项的第 n 个实例 - 2

    在我的应用程序中,我需要能够找到所有数字子字符串,然后扫描每个子字符串,找到第一个匹配范围(例如5到15之间)的子字符串,并将该实例替换为另一个字符串“X”。我的测试字符串s="1foo100bar10gee1"我的初始模式是1个或多个数字的任何字符串,例如,re=Regexp.new(/\d+/)matches=s.scan(re)给出["1","100","10","1"]如果我想用“X”替换第N个匹配项,并且只替换第N个匹配项,我该怎么做?例如,如果我想替换第三个匹配项“10”(匹配项[2]),我不能只说s[matches[2]]="X"因为它做了两次替换“1fooX0barXg

  4. ruby-on-rails - 在 ruby​​ 中使用 gsub 函数替换单词 - 2

    我正在尝试用ruby​​中的gsub函数替换字符串中的某些单词,但有时效果很好,在某些情况下会出现此错误?这种格式有什么问题吗NoMethodError(undefinedmethod`gsub!'fornil:NilClass):模型.rbclassTest"replacethisID1",WAY=>"replacethisID2andID3",DELTA=>"replacethisID4"}end另一个模型.rbclassCheck 最佳答案 啊,我找到了!gsub!是一个非常奇怪的方法。首先,它替换了字符串,所以它实际上修改了

  5. ruby-on-rails - 浏览 Ruby 源代码 - 2

    我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru

  6. ruby - 模块嵌套代码风格偏好 - 2

    我的假设是moduleAmoduleBendend和moduleA::Bend是一样的。我能够从thisblog找到解决方案,thisSOthread和andthisSOthread.为什么以及什么时候应该更喜欢紧凑语法A::B而不是另一个,因为它显然有一个缺点?我有一种直觉,它可能与性能有关,因为在更多命名空间中查找常量需要更多计算。但是我无法通过对普通类进行基准测试来验证这一点。 最佳答案 这两种写作方法经常被混淆。首先要说的是,据我所知,没有可衡量的性能差异。(在下面的书面示例中不断查找)最明显的区别,可能也是最著名的,是你的

  7. ruby - 寻找通过阅读代码确定编程语言的ruby gem? - 2

    几个月前,我读了一篇关于ruby​​gem的博客文章,它可以通过阅读代码本身来确定编程语言。对于我的生活,我不记得博客或gem的名称。谷歌搜索“ruby编程语言猜测”及其变体也无济于事。有人碰巧知道相关gem的名称吗? 最佳答案 是这个吗:http://github.com/chrislo/sourceclassifier/tree/master 关于ruby-寻找通过阅读代码确定编程语言的rubygem?,我们在StackOverflow上找到一个类似的问题:

  8. ruby - Net::HTTP 获取源代码和状态 - 2

    我目前正在使用以下方法获取页面的源代码:Net::HTTP.get(URI.parse(page.url))我还想获取HTTP状态,而无需发出第二个请求。有没有办法用另一种方法做到这一点?我一直在查看文档,但似乎找不到我要找的东西。 最佳答案 在我看来,除非您需要一些真正的低级访问或控制,否则最好使用Ruby的内置Open::URI模块:require'open-uri'io=open('http://www.example.org/')#=>#body=io.read[0,50]#=>"["200","OK"]io.base_ur

  9. 程序员如何提高代码能力? - 2

    前言作为一名程序员,自己的本质工作就是做程序开发,那么程序开发的时候最直接的体现就是代码,检验一个程序员技术水平的一个核心环节就是开发时候的代码能力。众所周知,程序开发的水平提升是一个循序渐进的过程,每一位程序员都是从“菜鸟”变成“大神”的,所以程序员在程序开发过程中的代码能力也是根据平时开发中的业务实践来积累和提升的。提高代码能力核心要素程序员要想提高自身代码能力,尤其是新晋程序员的代码能力有很大的提升空间的时候,需要针对性的去提高自己的代码能力。提高代码能力其实有几个比较关键的点,只要把握住这些方面,就能很好的、快速的提高自己的一部分代码能力。1、多去阅读开源项目,如有机会可以亲自参与开源

  10. 7个大一C语言必学的程序 / C语言经典代码大全 - 2

    嗨~大家好,这里是可莉!今天给大家带来的是7个C语言的经典基础代码~那一起往下看下去把【程序一】打印100到200之间的素数#includeintmain(){ inti; for(i=100;i 【程序二】输出乘法口诀表#includeintmain(){inti;for(i=1;i 【程序三】判断1000年---2000年之间的闰年#includeintmain(){intyear;for(year=1000;year 【程序四】给定两个整形变量的值,将两个值的内容进行交换。这里提供两种方法来进行交换,第一种为创建临时变量来进行交换,第二种是不创建临时变量而直接进行交换。1.创建临时变量来

随机推荐