淺談WebService的版本兼容性設(shè)計
在現(xiàn)在大型的項目或者軟件開發(fā)中,一般都會有很多種終端, PC端比如Winform、WebForm,移動端,比如各種Native客戶端(iOS, Android, WP),Html5等,我們要滿足以上所有這些客戶端的需求,實現(xiàn)前后端的分離,一種最常見的做法是,編寫WebService API來為以上客戶端提供數(shù)據(jù)。近年來越來越多的企業(yè)或者網(wǎng)站支持Restfull方式的WebServiceAPI,比如當當網(wǎng)開源Dubbox,擴展Dubbo服務(wù)框架支持REST風格遠程調(diào)用,這個是Java版本的,在.NET中ServiceStack天生支持Restfull風格的WebService。本文主要以ServiceStack為基礎(chǔ)探討,淺談WebService的兼容性設(shè)計。
1.軟件的兼容性
在軟件持續(xù)更新升級的過程中,API 也是需要不斷更新,這時就需要考慮客戶端升級以及兼容性的問題。當前有很多用戶可能由于多種原因,尤其是Native用戶,不可能及時升級到***版,所以需要提供對老版本的API的向后兼容。在API設(shè)計之初,我們需要考慮一些問題以及解決方法。
后向兼容性(Backward_compatibility),或者向下兼容,是指對于給定的輸入,較老版本的產(chǎn)品或者技術(shù),也能夠輸出相同的結(jié)果。如果一個產(chǎn)品或者API在設(shè)計之初就能夠為新的標準考慮,能夠滿足接收,讀取,查看舊的標準或者格式,那么這個產(chǎn)品就稱之為后向兼容,比如一些數(shù)據(jù)格式或者通訊協(xié)議,在新版本推出時都會充分考慮后向兼容問題。如果對一個產(chǎn)品的改進破壞了后向兼容性,則稱之為破壞性的改動(breaking change),相信大家都遇到過這種情況。
- App長久沒更新,落后很多個版本之后,再次打開改App會提示升級到***版,或者直接幫你強制升級。
- 使用新版的TortoiseSVN打開老版本TortoiseSVN創(chuàng)建的工程的時候,會提示需要升級項目工程才能打開。
這種情況一般發(fā)生在版本的改動比較大,或者對較老版本的支持成本比較大,在這種情況下,一般還需要為客戶提供從老版本遷移到新版本的工具或者解決方案。
兼容性有很多種類型比如 API 的兼容, 二進制dll的兼容性,以及數(shù)據(jù)文檔的兼容。
關(guān)于API的兼容性其實涉及到API的設(shè)計。相關(guān)文檔和書籍有很多,關(guān)于API設(shè)計的書可以參考Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries (2nd Edition) 和 How to Design a Good API and Why it Matters
本文主要探討WebService開發(fā)的API的向后兼容性。
#p#
2. WebService 的后向兼容性
在關(guān)于開發(fā)WebService框架上,這里不免又要談一下WCF和ServiceStack的設(shè)計理念和區(qū)別。
在ServiceStack中,鼓勵使用Message-based 式的設(shè)計,因為遠程服務(wù)調(diào)用是很耗時,我們應(yīng)該盡量一次多傳輸需要處理的數(shù)據(jù),而避免來回多次調(diào)用。在WCF中,通過一些工具,使得開發(fā)者能夠像調(diào)用本地方法一樣調(diào)用遠程方法,這樣會使人產(chǎn)生誤解,實際上調(diào)用遠程方法會比調(diào)用遠程方法慢上成千上萬倍。ServiceStack在設(shè)計之初就受Martine Flowler 的 Data Transfer Object 模式的啟發(fā):
“ When you're working with a remote interface, such as Remote Facade (388), each call to it is expensive. As a result you need to reduce the number of calls, and that means that you need to transfer more data with each call. One way to do this is to use lots of parameters. However, this is often awkward to program - indeed, it's often impossible with languages such as Java that return only a single value.
The solution is to create a Data Transfer Object that can hold all the data for the call. It needs to be serializable to go across the connection. Usually an assembler is used on the server side to transfer data between the DTO and any domain objects. ” |
在API的設(shè)計上WCF鼓勵將WebService作為普通的C#方法調(diào)用,這是一種基于普通的基于PRC 方式的調(diào)用。比如:
- public interface IWcfCustomerService
- {
- Customer GetCustomerById(int id);
- List<Customer> GetCustomerByIds(int[] id);
- Customer GetCustomerByUserName(string userName);
- List<Customer> GetCustomerByUserNames(string[] userNames);
- Customer GetCustomerByEmail(string email);
- List<Customer> GetCustomerByEmails(string[] emails);
- }
以上WebService方法就是通過id,username或者email獲取用戶或者用戶列表。如果使用ServiceStack的基于Message-base風格的API設(shè)計,接口就是:
- public class Customers : IReturn<List<Customer>>
- {
- public int[] Ids { get; set; }
- public string[] UserNames { get; set; }
- public string[] Emails { get; set; }
- }
在ServiceStack中,所有的請求信息都包裝在這個Customers的DTO中,他并不依賴于服務(wù)端方法的簽名。最簡單的好處在于使用message-base的設(shè)計在于wcf中的任意RPC組合都可以使用一個ServiceStack中的遠程消息組合,并且只需要服務(wù)端的一次實現(xiàn)。
閑話說了這么多,現(xiàn)在來看看如何設(shè)計WebService的后向兼容性。談到WebService,大家都會想到WCF,關(guān)于WCF的后向兼容,在Codeproject上,有人寫了三篇文章WCF Backwards Compatibility and Versioning Strategies(part1,part2,part3),由于ServiceStack僅支持Poco方式的請求參數(shù),并且寫在Poco中的字段都是必須的,沒有WCF 中的對字段的 [DataMember(IsRequired = true)] 和 [DataMember(IsRequired = false)] 來標識字段是否可選,所以WCF支持的RPC方式的參數(shù)(Part1文章中的后向兼容算法)ServiceStack中無法做測試,這里對比做Part2文章中的測試。并且測試的時候,測試添加和移除字段對Service調(diào)用的影響。
建立測試之前,我們先建立一個基本的ServiceStack程序。 這個程序和前文中介紹一樣,是一個簡單的 ServiceStack序。
#p#
3. 基礎(chǔ)
使用ServiceStack創(chuàng)建服務(wù),基本的工程結(jié)構(gòu)有三個。
ServiceModel這一層主要是定義 WebService中的請求參數(shù)和返回參數(shù)DTO, Employ中的代碼如下:
- namespace WebApplication1.ServiceModel
- {
- [Route("/Employ/{EmpId}/{EmpName}")]
- public class Employ : IReturn<EmployResponse>
- {
- public string EmpId { get; set; }
- public string EmpName { get; set; }
- }
- public class EmployResponse
- {
- public string EmpId { get; set; }
- public string EmpName { get; set; }
- }
- }
代碼定義了請求參數(shù)DTO Employ對象,約定了其返回類型為 EmployResponse,這里繼承IReturn< EmployResponse >是為了方便測試。 這里面指定了這個WebService的請求對象是Employ,返回對象是EmployResponse,并且通過’ /Employ/{EmpId}/{EmpName}’這樣的方式來調(diào)用服務(wù)為Employ對象賦值。
ServiceInterface這一層是服務(wù)實現(xiàn)層。里面的EmployServices直接繼承自ServiceStack中的Service對象。
- namespace WebApplication1.ServiceInterface
- {
- public class EmployServices : Service
- {
- public EmployResponse Any(Employ request)
- {
- return new EmployResponse { EmpId = request.EmpId, EmpName = request.EmpName};
- }
- }
- }
這里Any表示這個Restfull請求支持Post和Get兩種方式,請求參數(shù)類型Hello和返回值類型EmployResponse在Model中已經(jīng)定義。我們不關(guān)心這個方法的名稱,因為可以通過Rest路由來進行訪問。
WebApplication和ConsoleApplicaiton是ServiceInterface的服務(wù)宿主層,我們可以使用ASP.NET 將服務(wù)部署到IIS上,也可以通過控制臺程序進行部署以方便測試。
Web宿主很簡單,我們定義一個類繼承自AppHostbase,并提供包含有服務(wù)的程序集即可:
- namespace WebApplication1
- {
- public class AppHost : AppHostBase
- {
- /// <summary>
- /// Default constructor.
- /// Base constructor requires a name and assembly to locate web service classes.
- /// </summary>
- public AppHost()
- : base("WebApplication1", typeof(EmployServices).Assembly)
- {
- }
- /// <summary>
- /// Application specific configuration
- /// This method should initialize any IoC resources utilized by your web service classes.
- /// </summary>
- /// <param name="container"></param>
- public override void Configure(Container container)
- {
- //Config examples
- //this.AddPlugin(new PostmanFeature());
- //this.AddPlugin(new CorsFeature());
- }
- }
- }
然后,在網(wǎng)站啟動的時候,在Application_Start方法中初始化即可:
- namespace WebApplication1
- {
- public class Global : System.Web.HttpApplication
- {
- protected void Application_Start(object sender, EventArgs e)
- {
- new AppHost().Init();
- }
- }
- }
現(xiàn)在我們就可以通過Web的方式來查看我們創(chuàng)建的service服務(wù):
可以通過Post Http的方式采用Json格式調(diào)用WebService服務(wù),比如我們可以構(gòu)造Json格式,將內(nèi)容Post到 地址,http://localhost:28553/json/reply/ Employ:
- {"EmpId":"p1","EmpName":"zhangsan"}
返回值為:
- {"EmpId":"p1","EmpName":"zhangsan"}
或者直接在地址欄里輸入:http://localhost:28553/Employ/p1/zhangshan
不過在開發(fā)的時候,我們通常采用***種方式,將參數(shù)序列化為json字符串,然后post到我們部署的地址上。
以上是服務(wù)端代碼,部署好了之后,客戶端需要進行調(diào)用,調(diào)用的時候,我們需要引用ServiceModel這里面的請求和返回值實體類型。在部署了WebService之后,我們也可以通過引用WebService的方式來進行引用字段。
新建一個控制臺應(yīng)用程序,將上面的ServiceModel編譯為dll之后,拷貝到新建的控制臺程序下面,然后引用這個dll,客戶端調(diào)用代碼如下,我們采用了Json的方式傳送數(shù)據(jù),當然您可以選擇其他的數(shù)據(jù)格式進行傳輸。代碼如下:
- class Program
- {
- static void Main(string[] args)
- {
- Console.Title = "ServiceStack Console Client";
- using (var client = new JsonServiceClient("http://localhost:28553/"))
- {
- EmployResponse employResponse = client.Send<EmployResponse>(new Employ { EmpId="1", EmpName="zhangshan"});
- if (employResponse != null)
- {
- Console.WriteLine(string.Format("EmoplyId:{0},EmployName:{1}",employResponse.EmpId,employResponse.EmpName));
- }
- }
- Console.ReadKey();
- }
- }
把服務(wù)端代碼運行起來之后,然后運行上面的控制臺程序,輸出如下:
- EmoplyId:p1,EmployName:zhangshan
#p#
4. 測試
4.1 Case 1 添加新字段
現(xiàn)在假設(shè)我們v1版本的API中Employ實體只有兩個字段,后來我們發(fā)現(xiàn),在v2版本中,還需要添加一個Address字段,以表示該雇員的地址,于是我們修改了Model,添加了Address字段,在Request和Response中均修改了該字段,現(xiàn)在服務(wù)端代碼如下:
- namespace WebApplication1.ServiceModel
- {
- [Route("/Employ/{EmpId}/{EmpName}")]
- public class Employ : IReturn<EmployResponse>
- {
- public string EmpId { get; set; }
- public string EmpName { get; set; }
- public string Address { get; set; }
- }
- public class EmployResponse
- {
- public string EmpId { get; set; }
- public string EmpName { get; set; }
- public string Address { get; set; }
- }
- }
- namespace WebApplication1.ServiceInterface
- {
- public class EmployServices : Service
- {
- public EmployResponse Any(Employ request)
- {
- return new EmployResponse { EmpId = request.EmpId, EmpName = request.EmpName, Address = request.Address };
- }
- }
- }
然后編譯運行。需要注意的是,客戶端引用的ServiceModel這個dll,依然是之前的老版本的只有兩個字段的dll,現(xiàn)在再次運行客戶端,輸出如下:
- EmoplyId:0,EmployName:zhangshan
結(jié)果和之前的一抹一樣,這表示,對新的API添加新的字段和在返回值中添加新的字段,不會對就有的WebService產(chǎn)生影響。
4.2 Case 2 :修改數(shù)據(jù)字段的類型
再后來,在V3版本中,我們發(fā)現(xiàn)EmpID應(yīng)該是一個int型,于是我們將服務(wù)端的Employ實體的EmployID從string類型改為了int型,然后運行客戶端,因為在客戶端,我們傳給ID的是string類型的”p1”該類型不能直接轉(zhuǎn)換為int型,真實的輸出的結(jié)果是:
- EmoplyId:0,EmployName:zhangshan
沒有報錯,但是不是我們期望的結(jié)果。客戶端將EmpolyID字段傳”p1”過去的時候,服務(wù)端該字段類型已經(jīng)變更為了int,”p1”沒有轉(zhuǎn)換為int型,所以會使用int的默認初始值0代替。
4.3 Case 3:移除必要字段
現(xiàn)在我們編譯一下ServiceModel,然后拷貝到ServiceClint更新一下客戶端的dll引用,這樣客戶端就能夠獲取到Address這個字段了。如果是WebService的話,直接更新一下引用就可以?,F(xiàn)在我們修改客戶端,請求的時候為Address賦值。
- static void Main(string[] args)
- {
- Console.Title = "ServiceStack Console Client";
- using (var client = new JsonServiceClient("http://localhost:28553/"))
- {
- EmployResponse employResponse = client.Send<EmployResponse>(new Employ { EmpId="p1", EmpName="zhangshan",Address="sh"});
- if (employResponse != null)
- {
- Console.WriteLine(string.Format("EmoplyId:{0},EmployName:{1},Work at:{2}",employResponse.EmpId,employResponse.EmpName,employResponse.Address));
- }
- }
- Console.ReadKey();
- }
可以看到這是客戶端已經(jīng)更新EmpId已經(jīng)是int型了,如果在傳p1的話,會報錯。現(xiàn)在編譯運行,輸出結(jié)果應(yīng)該是:
- EmoplyId:1,EmployName:zhangshan,Work at:sh
現(xiàn)在,在V4版本中,我們發(fā)現(xiàn)v2版本中添加的工作地址Address這個字段不應(yīng)該放在Employ中,所以在服務(wù)端將該字段移除,并進行了重新部署??蛻舳硕嗽俅芜\行,結(jié)果如下:
- EmoplyId:1,EmployName:zhangshan,Work at:
可以看到,服務(wù)端去除了Address字段后,服務(wù)端返回的原始數(shù)據(jù)中缺失Address元素,客戶端在反序列化的時候找不帶該字段就賦值為了null。
#p#
5. 總結(jié)
如果使用ServiceStack,在API的進化過程中,新版本的API可能較老版本的API會有如下修改:
- 添加,移除,或者重命名字段。
- 將字段的類型從int轉(zhuǎn)換為了double或者long,這種可以隱式轉(zhuǎn)換的類型。
- 將集合類型從List改為了HashSet。
- 將強類型集合改為了松散類型的List集合改為了松散的List里面的元素為Dictionary 。
- 添加了新的枚舉類型。
- 添加了可空的字段。
在客戶端序列化實體后,傳到服務(wù)端的時候,序列化工具會自動的處理以上類型的變更和不一致,如果字段對應(yīng)不上,或者類型轉(zhuǎn)換不過去,則會使用服務(wù)端的字段的類型默認值替代。
對于API的兼容性策略,有以下幾個注意點:
5.1應(yīng)該對現(xiàn)有API版本的進化來解決,而不是重新實現(xiàn)一個
僅添加了一個新字段的參數(shù)。因為,如果要同時維護多個不同版本的,但是實現(xiàn)相同功能的API可能會使得工作量巨大,而且容易出錯。在編寫***個版本的API之初,就應(yīng)該遵守這一約定。同時編寫多個版本的實現(xiàn)相同或相似功能的API違反了DRY原則。
5.2要充分利用自建的序列化工具的版本功能優(yōu)勢
一些序列化工具會在字段對應(yīng)不上的時候,給字段附上改字段類型的默認值;能夠在相同結(jié)構(gòu)的集合類型之間進行自動轉(zhuǎn)換;能夠進行類型的隱式轉(zhuǎn)換等等。比如,如果一個id在較老的api中使用的是int類型,那么在新版本中,我們可以直接將其更改為long類型就可以向后兼容。
增強現(xiàn)有服務(wù)對變化的防御性
在WCF中,使用DataContract可以自由添加或者移除字段而不會產(chǎn)生breaking change。這主要是在于其實現(xiàn)了IExtensibleDataObject接口。在返回類型的DTO對象上,如果實現(xiàn)該接口,也能實現(xiàn)該功能。在兼容舊版本的DTOs的時候,要做好充分測試,一般的:
- 不要修改已存在字段的數(shù)據(jù)類型,如果確實需要修改,添加另外一個屬性,并且根據(jù)具有的字段來判斷版本。
- 防御性編程,要判斷在老版本客戶端中那些字段可能不存在,所以不要強制認為一定需要存在。
- 保證只有一個唯一的全局命名空間。這個可以在程序的AssemblyInfo.cs中處理,一般的我們定義一個公共的AssemblyInfo,然后在各個DTO項目中,進行鏈接引用。
5.3 在DTO中添加版本控制信息
這個也是最容易想到和實現(xiàn)的。在大多數(shù)情況下,如果使用防御性編程的思想并且對API進行平滑演進的話,通??梢愿鶕?jù)數(shù)據(jù)來推斷出客戶端的版本。但是在一些特殊情況下,服務(wù)端需要根據(jù)客戶端的特定版本來處理相應(yīng),因此可以在請求DTO中添加版本信息。舉例如下:
比如在最初發(fā)布Empoly這個服務(wù)的時候,沒有多想直接定義了下面這個請求的DTO:
- class Employ
- {
- string Name { get; set; }
- }
然后由于某些原因應(yīng)用的UI發(fā)生了變化,我們不想給客戶返回這個籠統(tǒng)的Name屬性,需要追蹤客戶端使用的版本,來考慮返回那個值,于是DTO修改為了如下,并且添加了新字段DisplayName和Age:
- class Employ
- {
- Employ()
- {
- Version = 1;
- }
- int Version;
- string Name;
- string DisplayName;
- int Age;
- }
然而,經(jīng)過會議討論,發(fā)現(xiàn)DisplayName仍然不太好,***能夠把它拆分成兩個字段,并且存儲Age不好,應(yīng)該存儲DateOfBirth 于是我們的DTO變成了這樣:
- class Employ
- {
- Employ()
- {
- Version = 2;
- }
- int Version;
- string Name;
- string DisplayName;
- string FirstName;
- string LastName;
- DateTime? DateOfBirth;
- }
到目前位置,客戶端有三個版本,他們給服務(wù)端發(fā)送請求如下:
V1版本:
- client.Send<EmployResponse>(new Employ { Name = "zhangshan" });
V2版本:
- client.Send<EmployResponse>(new Employ { Name = "zhangshan" DisplayName="Ms Zhang", Age=22 });
V3版本:
- client.Send<EmployResponse>(new Employ { FirstName = "zhang" LastName="shan", DateOfBirth=new DateTime(1992,01,01) });
現(xiàn)在,服務(wù)端可以統(tǒng)一處理以上三個版本客戶端的請求:
- public class EmployServices : Service
- {
- public EmployResponse Any(Employ request)
- {
- //V1:
- request.Version == 0;
- request.Name = "zhangshan";
- request.DisplayName == null;
- request.Age = 0;
- request.DateOfBirth = null;
- //v2:
- request.Version == 2;
- request.Name == null;
- request.DisplayName == "Foo Bar";
- request.Age = 18;
- request.DateOfBirth = null;
- //v3:
- request.Version == 3;
- request.Name == null;
- request.DisplayName == null;
- request.FirstName == "Foo";
- request.LastName == "Bar";
- request.Age = 0;
- request.DateOfBirth = new DateTime(1994, 01, 01);
- //.....
- }
- }
5.4 要遵循”最小化”原則
***一點應(yīng)該是在實踐中的經(jīng)驗總結(jié)。和我們再SQLServer中寫查詢條件一樣,千萬不要圖方便使用select * 來代替要用到的需要手動輸入的字段,除去效率原因,因為一旦字段查詢的字段順序發(fā)生變動,可能就會影響到解析。在設(shè)計DTO的可選值的時候,也需要考慮這樣的問題。這里舉一個例子:
比如我們要設(shè)計一個查找附件商戶的API,該API支持查找附件的酒店,餐飲以及不限。所以我們一般會在DTO中定義一個表示查找范圍的字段SearchScope,并定義一些枚舉值。
0-不限 1-酒店 2-餐飲
這里需要注意的是,如果我們在***個版本中,一開始就使用0表示不限的話,在實現(xiàn)中,比如在SQL語句中,我們通常會不會查詢的條件作出限制,這樣就正常的發(fā)出了***個版本。
然而,在第二個版本中,我們添加的對附近娛樂場所的支持,并且開發(fā)了對娛樂場所搜索結(jié)果的特殊頁面支持。于是,自然而然的考慮到在SearchScope中添加一個枚舉值表示娛樂,并且在DB也增加了對娛樂場所信息的存儲。
3- 娛樂
然后V2版本的接口順利發(fā)布了。這個時候,如果DTO中沒有版本信息,問題就來了,在V1版本中,當用戶搜索的時候,如果選擇不限,那么搜索結(jié)果中就會出現(xiàn) “娛樂” 相關(guān)信息,但是點擊搜索出來的”娛樂”的結(jié)果的時候,其他頁面在V1版本的時候,沒有做相應(yīng)的頁面處理。所以就會出現(xiàn)一些問題。所以發(fā)布V2版本的API的時候,需要修改V1版本的處理邏輯。
所以當初在設(shè)計V1版本的API 的時候,對于條件或者組合不太多的情況,對于”不限”這種場景,我們應(yīng)該是傳所有支持的類別,比如傳”1,2”而不是”0”?;蛘咴谠O(shè)計范圍的時候,設(shè)計成可以進行或”|”方式的枚舉值,比如設(shè)計成 0-不限,1-酒店,2-餐飲,4-娛樂等。這樣新版本發(fā)布后對叫老版本的API影響比較少。
本文簡單介紹了一下軟件兼容性的幾個方面,并以ServiceStack為例,簡要討論了,在設(shè)計WebService的時候如何考慮后向兼容性問題,并給出了一些建議,希望本文對您了解WebService API的兼容性有所幫助。