小失誤,大問題 —— 為已釋出的介面更名

邊城發表於2021-12-11

寫程式碼難免出現失誤。在對某些已經發布的庫進行升級或者審查的時候,就有可能會發現一些介面名稱需要變更。比如,早期命名不符合特定規範,或者出現了難以發現的拼寫錯誤等。有錯當然是要改的,但是直接更名會影響到已釋出的介面。粗暴的名稱變更本質上是刪除了舊介面,建立了新介面,對 API 使用者來說極具破壞性 —— 使用者會發現所有用到這些介面的地方都編譯不過,或者不能執行了,這簡直就是一場災難。

本文主要以 C# 為例介紹對庫介面更名的處理 —— 在 Assembly 內部,直接使用“重新命名”重構方法,藉助 IDE/Editor 的能力就可以完成變更;但是對於開放出去的介面,就必須保持介面相容性,並宣告過期時間。

本文以 C# 為例,但是處理方式和重構思想是語言無關的!

處理不符合規範的類屬性命名

這一節主要是解決屬性更名的問題。方法更名和屬性更名是同樣的道理,後面不再贅述。

假設有如下定義:

public class ActionResult : IActionResult {
    public int code { get; set; }
    public string message { get; set; }
}

這個定義命名不符合“公開屬性使用 Pascal Case 命名規則”的規範。但作為一個已經被廣泛使用的庫,直接重新命名將產生巨大的破壞,所以這裡應該按正確的名稱新增屬性,並將舊的屬性宣告為過期。

public class ActionResult : IActionResult {
    public int Code { get; set; }
    public string Message { get; set; }
    
    [Obsolete("因規範命名,將從 v3.4 版本中刪除,請使用 Code 代替")]
    public int code {
        get => Code;
        set => Code = value;
    }
    
    [Obsolete("因規範命名,將從 v3.4 版本中刪除,請使用 Message 代替")]
    public string message {
        get => Message;
        set => Message = value;
    }
}

不過修改後的 ActionResult 會在序列化的時候出現問題。庫中使用了 Newtonsoft JSON 來得到 JSON,按專案中的 JSON 規範,鍵名會使用 Camel Case 命名規則,所以在配置中新增了 CamelCasePropertyNamesContractResolver 例項。那麼 ActionResultCodecode 屬性都會被處理為 "code" 這個鍵名,於是發生了重名的衝突。

為了解決這個問題,可以為重名屬性中的一個新增 Newtonsoft JSON 的 [JsonIgnore] 特性宣告。為了保持相容性,應該在新加的 CodeMessage 屬性上新增這一宣告,並在 Obsolete 描述和相關文件、發行說明中充分說明這一情況,告訴使用者這一變更可能產生序列化問題。

也許你已經發現了,在這個問題上,我們作為庫的發行方始終不能保持完全的相容,畢竟使用者使用什麼樣的序列化方式不是我們能決定的。也許使用者會序列化成 XML,也許使用者不使用 Newtonsoft JSON 而是使用別的 JSON 序列化工具,也許使用者是需要遍歷所有屬性來實現某種邏輯 —— 作為庫的發行方,我們除了通知和警告,無能為力 —— 這也是很多知名大庫會把一些奇怪的介面保留很久的原因之一。

處理不符合規範的介面屬性

注意到,上面示例的類實現了 IActionResult 介面。codemessage 的命名規範問題其實是從介面引入的,所以我們還需要處理介面屬性的命名。處理介面屬性會更為繁瑣,但好在 C# 8.0 引入了介面預設實現的特性。假設之前 IActionResult 介面定義如下

public interface IActionResult {
    int code { get; set; }
    string message { get; set; }
}

使用預設實現新增 CodeMessage 並宣告原來的屬性即將失效

public interface IActionResult {
    int Code { get => code; set => code == value; }
    string Message { get => message; set => message = value; }

    [Obsolete("因規範命名,將從 v4.0 版本中刪除,請改為實現 Code 屬性")]
    int code { get; set; }

    [Obsolete("因規範命名,將從 v4.0 版本中刪除,請改為實現 Message 屬性")]
    string message { get; set; }
}

變更之後,前述 ActionResult 規範化前後的程式碼都可以通過編譯。

這裡特別需要注意的是,介面變更影響會比實現(類)的影響更大,應該給予使用者足夠的修改時間來處理,通常會在下個大版本或者越過幾個大版本之後才實際刪除宣告為 Obsolete 的介面,進行破壞性的升級。

處理類名中的拼寫錯誤

在程式碼審查的過程中,發現類 ApiErrorActioin 的名字中出現了拼寫錯誤(追究為什麼會讓這樣的錯誤出現在已釋出的庫中是有必要的,因為這通常是因為過程管理中存在漏洞,但不是本文的研究內容)。

直接更改類名同樣是極具破壞性的,我們可以使用“重新命名”重構方法更正名稱,再新增一個與之存在繼承關係,但保持原來錯誤名稱的類來處理。

先使用重新命名重構方法將 ApiErrorActioin 更正名稱為 ApiErrorAction。現在所有 Assembly 內的錯誤名稱都更正了。然後再定義一個繼承自 ApiErrorActionApiErrorActioin(注意要實現與原來相同簽名的所有建構函式過載),並新增廢棄宣告。

[Obsolete("因為拼寫錯誤將從 v3.5 版本中刪除,請使用正確命名的 ApiErrorAction")]
class ApiErrorActioin : ApiErrorAction {
    // 建構函式示例
    public ApiErrorActioin(string name): base(name) { }
}

這裡需要特別注意的是,為保持相容性而存在的 ApiErrorActioin 除了保持原來的非私有建構函式介面之外,其他介面都可以直接從修正後的 ApiErrorAction 繼承而來。而且,這個要廢棄的 ApiErrorActioin不應該包含任何邏輯程式碼

對介面更名也可以採用類似的方法。但要注意的是,這種處理方式新增了繼承層次。雖然一般情況下不會造成使用者的困擾,但是如果使用者的“反射”程式碼有涉及到繼承層次的邏輯,就有可能出現問題。因此這樣的變更同樣需要在文件中註明併發出警告。

小錯誤,大問題

對於人類來說,大腦的自動修正能力非常強(真正的人工智慧!)所以一個小小的拼寫錯誤或者大小字錯誤並不是什麼大問題。但對計算機來說,只差一個字元,那就是完全不同的兩個標識。更正拼寫本身是個小事,但對於公共庫介面的更名,可能會對使用者產生巨大的影響,需要非常謹慎地處理。

然而,即使我們非常謹慎的處理了能想到的各種相容性問題,差異仍然是不可避免的。你永遠不知道使用者會怎麼使用這個庫,所以也不知道使用者會因為變更遇到什麼奇怪的問題 —— 所以請重視文件和發行說明中的詳細描述和警告。問題是藏不住的,公佈它才是正確的選擇。

相關文章