在现在大型的项目或者软件开发中,一般都会有很多种终端, PC端比如Winform、WebForm,移动端,比如各种Native客户端(iOS, Android, WP),Html5等,我们要满足以上所有这些客户端的需求,实现前后端的分离,一种最常见的做法是,编写WebService API来为以上客户端提供数据。近年来越来越多的企业或者网站支持Restfull方式的WebServiceAPI,比如当当网开源Dubbox,扩展Dubbo服务框架支持REST风格远程调用,这个是Java版本的,在.NET中ServiceStack天生支持Restfull风格的WebService。本文主要以ServiceStack为基础探讨,浅谈WebService的兼容性设计。
1.软件的兼容性
在软件持续更新升级的过程中,API 也是需要不断更新,这时就需要考虑客户端升级以及兼容性的问题。当前有很多用户可能由于多种原因,尤其是Native用户,不可能及时升级到***版,所以需要提供对老版本的API的向后兼容。在API设计之初,我们需要考虑一些问题以及解决方法。
后向兼容性(Backward_compatibility),或者向下兼容,是指对于给定的输入,较老版本的产品或者技术,也能够输出相同的结果。如果一个产品或者API在设计之初就能够为新的标准考虑,能够满足接收,读取,查看旧的标准或者格式,那么这个产品就称之为后向兼容,比如一些数据格式或者通讯协议,在新版本推出时都会充分考虑后向兼容问题。如果对一个产品的改进破坏了后向兼容性,则称之为破坏性的改动(breaking change),相信大家都遇到过这种情况。
- App长久没更新,落后很多个版本之后,再次打开改App会提示升级到***版,或者直接帮你强制升级。
- 使用新版的TortoiseSVN打开老版本TortoiseSVN创建的工程的时候,会提示需要升级项目工程才能打开。
这种情况一般发生在版本的改动比较大,或者对较老版本的支持成本比较大,在这种情况下,一般还需要为客户提供从老版本迁移到新版本的工具或者解决方案。
兼容性有很多种类型比如 API 的兼容, 二进制dll的兼容性,以及数据文档的兼容。
关于API的兼容性其实涉及到API的设计。相关文档和书籍有很多,关于API设计的书可以参考Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries (2nd Edition) 和 How to Design a Good API and Why it Matters
本文主要探讨WebService开发的API的向后兼容性。
#p#
2. WebService 的后向兼容性
在关于开发WebService框架上,这里不免又要谈一下WCF和ServiceStack的设计理念和区别。
在ServiceStack中,鼓励使用Message-based 式的设计,因为远程服务调用是很耗时,我们应该尽量一次多传输需要处理的数据,而避免来回多次调用。在WCF中,通过一些工具,使得开发者能够像调用本地方法一样调用远程方法,这样会使人产生误解,实际上调用远程方法会比调用远程方法慢上成千上万倍。ServiceStack在设计之初就受Martine Flowler 的 Data Transfer Object 模式的启发:
“ 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的设计上WCF鼓励将WebService作为普通的C#方法调用,这是一种基于普通的基于PRC 方式的调用。比如:
- 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设计,接口就是:
- public class Customers : IReturn<List<Customer>>
- {
- public int[] Ids { get; set; }
- public string[] UserNames { get; set; }
- public string[] Emails { get; set; }
- }
在ServiceStack中,所有的请求信息都包装在这个Customers的DTO中,他并不依赖于服务端方法的签名。最简单的好处在于使用message-base的设计在于wcf中的任意RPC组合都可以使用一个ServiceStack中的远程消息组合,并且只需要服务端的一次实现。
闲话说了这么多,现在来看看如何设计WebService的后向兼容性。谈到WebService,大家都会想到WCF,关于WCF的后向兼容,在Codeproject上,有人写了三篇文章WCF Backwards Compatibility and Versioning Strategies(part1,part2,part3),由于ServiceStack仅支持Poco方式的请求参数,并且写在Poco中的字段都是必须的,没有WCF 中的对字段的 [DataMember(IsRequired = true)] 和 [DataMember(IsRequired = false)] 来标识字段是否可选,所以WCF支持的RPC方式的参数(Part1文章中的后向兼容算法)ServiceStack中无法做测试,这里对比做Part2文章中的测试。并且测试的时候,测试添加和移除字段对Service调用的影响。
建立测试之前,我们先建立一个基本的ServiceStack程序。 这个程序和前文中介绍一样,是一个简单的 ServiceStack序。
#p#
3. 基础
使用ServiceStack创建服务,基本的工程结构有三个。
ServiceModel这一层主要是定义 WebService中的请求参数和返回参数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; }
- }
- }
代码定义了请求参数DTO Employ对象,约定了其返回类型为 EmployResponse,这里继承IReturn< EmployResponse >是为了方便测试。 这里面指定了这个WebService的请求对象是Employ,返回对象是EmployResponse,并且通过’ /Employ/{EmpId}/{EmpName}’这样的方式来调用服务为Employ对象赋值。
ServiceInterface这一层是服务实现层。里面的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两种方式,请求参数类型Hello和返回值类型EmployResponse在Model中已经定义。我们不关心这个方法的名称,因为可以通过Rest路由来进行访问。
WebApplication和ConsoleApplicaiton是ServiceInterface的服务宿主层,我们可以使用ASP.NET 将服务部署到IIS上,也可以通过控制台程序进行部署以方便测试。
Web宿主很简单,我们定义一个类继承自AppHostbase,并提供包含有服务的程序集即可:
- 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());
- }
- }
- }
然后,在网站启动的时候,在Application_Start方法中初始化即可:
- namespace WebApplication1
- {
- public class Global : System.Web.HttpApplication
- {
- protected void Application_Start(object sender, EventArgs e)
- {
- new AppHost().Init();
- }
- }
- }
现在我们就可以通过Web的方式来查看我们创建的service服务:
可以通过Post Http的方式采用Json格式调用WebService服务,比如我们可以构造Json格式,将内容Post到 地址,http://localhost:28553/json/reply/ Employ:
- {"EmpId":"p1","EmpName":"zhangsan"}
返回值为:
- {"EmpId":"p1","EmpName":"zhangsan"}
或者直接在地址栏里输入:http://localhost:28553/Employ/p1/zhangshan
不过在开发的时候,我们通常采用***种方式,将参数序列化为json字符串,然后post到我们部署的地址上。
以上是服务端代码,部署好了之后,客户端需要进行调用,调用的时候,我们需要引用ServiceModel这里面的请求和返回值实体类型。在部署了WebService之后,我们也可以通过引用WebService的方式来进行引用字段。
新建一个控制台应用程序,将上面的ServiceModel编译为dll之后,拷贝到新建的控制台程序下面,然后引用这个dll,客户端调用代码如下,我们采用了Json的方式传送数据,当然您可以选择其他的数据格式进行传输。代码如下:
- 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();
- }
- }
把服务端代码运行起来之后,然后运行上面的控制台程序,输出如下:
- EmoplyId:p1,EmployName:zhangshan
#p#
4. 测试
4.1 Case 1 添加新字段
现在假设我们v1版本的API中Employ实体只有两个字段,后来我们发现,在v2版本中,还需要添加一个Address字段,以表示该雇员的地址,于是我们修改了Model,添加了Address字段,在Request和Response中均修改了该字段,现在服务端代码如下:
- 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,现在再次运行客户端,输出如下:
- EmoplyId:0,EmployName:zhangshan
结果和之前的一抹一样,这表示,对新的API添加新的字段和在返回值中添加新的字段,不会对就有的WebService产生影响。
4.2 Case 2 :修改数据字段的类型
再后来,在V3版本中,我们发现EmpID应该是一个int型,于是我们将服务端的Employ实体的EmployID从string类型改为了int型,然后运行客户端,因为在客户端,我们传给ID的是string类型的”p1”该类型不能直接转换为int型,真实的输出的结果是:
- EmoplyId:0,EmployName:zhangshan
没有报错,但是不是我们期望的结果。客户端将EmpolyID字段传”p1”过去的时候,服务端该字段类型已经变更为了int,”p1”没有转换为int型,所以会使用int的默认初始值0代替。
4.3 Case 3:移除必要字段
现在我们编译一下ServiceModel,然后拷贝到ServiceClint更新一下客户端的dll引用,这样客户端就能够获取到Address这个字段了。如果是WebService的话,直接更新一下引用就可以。现在我们修改客户端,请求的时候为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();
- }
可以看到这是客户端已经更新EmpId已经是int型了,如果在传p1的话,会报错。现在编译运行,输出结果应该是:
- EmoplyId:1,EmployName:zhangshan,Work at:sh
现在,在V4版本中,我们发现v2版本中添加的工作地址Address这个字段不应该放在Employ中,所以在服务端将该字段移除,并进行了重新部署。客户端端再次运行,结果如下:
- EmoplyId:1,EmployName:zhangshan,Work at:
可以看到,服务端去除了Address字段后,服务端返回的原始数据中缺失Address元素,客户端在反序列化的时候找不带该字段就赋值为了null。
#p#
5. 总结
如果使用ServiceStack,在API的进化过程中,新版本的API可能较老版本的API会有如下修改:
- 添加,移除,或者重命名字段。
- 将字段的类型从int转换为了double或者long,这种可以隐式转换的类型。
- 将集合类型从List改为了HashSet。
- 将强类型集合改为了松散类型的List集合改为了松散的List里面的元素为Dictionary 。
- 添加了新的枚举类型。
- 添加了可空的字段。
在客户端序列化实体后,传到服务端的时候,序列化工具会自动的处理以上类型的变更和不一致,如果字段对应不上,或者类型转换不过去,则会使用服务端的字段的类型默认值替代。
对于API的兼容性策略,有以下几个注意点:
5.1应该对现有API版本的进化来解决,而不是重新实现一个
仅添加了一个新字段的参数。因为,如果要同时维护多个不同版本的,但是实现相同功能的API可能会使得工作量巨大,而且容易出错。在编写***个版本的API之初,就应该遵守这一约定。同时编写多个版本的实现相同或相似功能的API违反了DRY原则。
5.2要充分利用自建的序列化工具的版本功能优势
一些序列化工具会在字段对应不上的时候,给字段附上改字段类型的默认值;能够在相同结构的集合类型之间进行自动转换;能够进行类型的隐式转换等等。比如,如果一个id在较老的api中使用的是int类型,那么在新版本中,我们可以直接将其更改为long类型就可以向后兼容。
增强现有服务对变化的防御性
在WCF中,使用DataContract可以自由添加或者移除字段而不会产生breaking change。这主要是在于其实现了IExtensibleDataObject接口。在返回类型的DTO对象上,如果实现该接口,也能实现该功能。在兼容旧版本的DTOs的时候,要做好充分测试,一般的:
- 不要修改已存在字段的数据类型,如果确实需要修改,添加另外一个属性,并且根据具有的字段来判断版本。
- 防御性编程,要判断在老版本客户端中那些字段可能不存在,所以不要强制认为一定需要存在。
- 保证只有一个唯一的全局命名空间。这个可以在程序的AssemblyInfo.cs中处理,一般的我们定义一个公共的AssemblyInfo,然后在各个DTO项目中,进行链接引用。
5.3 在DTO中添加版本控制信息
这个也是最容易想到和实现的。在大多数情况下,如果使用防御性编程的思想并且对API进行平滑演进的话,通常可以根据数据来推断出客户端的版本。但是在一些特殊情况下,服务端需要根据客户端的特定版本来处理相应,因此可以在请求DTO中添加版本信息。举例如下:
比如在最初发布Empoly这个服务的时候,没有多想直接定义了下面这个请求的DTO:
- class Employ
- {
- string Name { get; set; }
- }
然后由于某些原因应用的UI发生了变化,我们不想给客户返回这个笼统的Name属性,需要追踪客户端使用的版本,来考虑返回那个值,于是DTO修改为了如下,并且添加了新字段DisplayName和Age:
- class Employ
- {
- Employ()
- {
- Version = 1;
- }
- int Version;
- string Name;
- string DisplayName;
- int Age;
- }
然而,经过会议讨论,发现DisplayName仍然不太好,***能够把它拆分成两个字段,并且存储Age不好,应该存储DateOfBirth 于是我们的DTO变成了这样:
- class Employ
- {
- Employ()
- {
- Version = 2;
- }
- int Version;
- string Name;
- string DisplayName;
- string FirstName;
- string LastName;
- DateTime? DateOfBirth;
- }
到目前位置,客户端有三个版本,他们给服务端发送请求如下:
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) });
现在,服务端可以统一处理以上三个版本客户端的请求:
- 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 要遵循”最小化”原则
***一点应该是在实践中的经验总结。和我们再SQLServer中写查询条件一样,千万不要图方便使用select * 来代替要用到的需要手动输入的字段,除去效率原因,因为一旦字段查询的字段顺序发生变动,可能就会影响到解析。在设计DTO的可选值的时候,也需要考虑这样的问题。这里举一个例子:
比如我们要设计一个查找附件商户的API,该API支持查找附件的酒店,餐饮以及不限。所以我们一般会在DTO中定义一个表示查找范围的字段SearchScope,并定义一些枚举值。
0-不限 1-酒店 2-餐饮
这里需要注意的是,如果我们在***个版本中,一开始就使用0表示不限的话,在实现中,比如在SQL语句中,我们通常会不会查询的条件作出限制,这样就正常的发出了***个版本。
然而,在第二个版本中,我们添加的对附近娱乐场所的支持,并且开发了对娱乐场所搜索结果的特殊页面支持。于是,自然而然的考虑到在SearchScope中添加一个枚举值表示娱乐,并且在DB也增加了对娱乐场所信息的存储。
3- 娱乐
然后V2版本的接口顺利发布了。这个时候,如果DTO中没有版本信息,问题就来了,在V1版本中,当用户搜索的时候,如果选择不限,那么搜索结果中就会出现 “娱乐” 相关信息,但是点击搜索出来的”娱乐”的结果的时候,其他页面在V1版本的时候,没有做相应的页面处理。所以就会出现一些问题。所以发布V2版本的API的时候,需要修改V1版本的处理逻辑。
所以当初在设计V1版本的API 的时候,对于条件或者组合不太多的情况,对于”不限”这种场景,我们应该是传所有支持的类别,比如传”1,2”而不是”0”。或者在设计范围的时候,设计成可以进行或”|”方式的枚举值,比如设计成 0-不限,1-酒店,2-餐饮,4-娱乐等。这样新版本发布后对叫老版本的API影响比较少。
本文简单介绍了一下软件兼容性的几个方面,并以ServiceStack为例,简要讨论了,在设计WebService的时候如何考虑后向兼容性问题,并给出了一些建议,希望本文对您了解WebService API的兼容性有所帮助。