PAYATHON 2026

ServiceStack REST API 版本管理如何兼容旧客户端?

技术老齐

建议保留旧 DTO、旧路由和旧行为,在发生破坏性变更时新增一套契约,让两套接口在过渡期内同时运行。对现有系统来说,使用明确的版本路径通常最容易实施,例如保留 /projects,新增 /v20231/projects。

ServiceStack 的 client.Version、IHasVersion 和 AddImplicitVersion 可以传递客户端使用的契约版本。DTO 切换、版本兼容性判断,以及让 API Explorer 按版本展示历史接口,都需要另外实现。

现有配置为什么没有实现版本选择?

配置或约定作用不会自动完成的工作
HostConfig.ApiVersion设置应用级 API 版本信息为每个服务声明支持的版本、选择处理方法
AddImplicitVersion在生成的客户端 request DTO 中添加并初始化版本属性修改服务端已有 DTO、切换请求和响应类型
client.Version自动填充实现 IHasVersion 的请求 DTO 的 Version将 GetProjects 转换成 GetProjectsV20231
?v=20231为实现 IHasVersion 的请求 DTO 提供版本值的后备绑定方式按版本参数自动选择另一套 DTO

AddImplicitVersion 是客户端代码生成配置。例如,生成的请求类可以包含默认值为 20231 的 Version 属性,服务端仍需自行读取和处理版本信息。详见 ServiceStack 客户端 DTO 生成选项。

client.Version 的基本用法如下:

public class GetProjects : IReturn<List<Project>>, IHasVersion
{
    public int Version { get; set; }
    public string SearchCriteria { get; set; }
}
client.Version = 20201;
var projects = client.Get(new GetProjects());

这里发送的仍是 GetProjects,返回类型由 IReturn<List<Project>> 确定。即使把版本改成 20231,响应结构也已经变化,客户端编译时使用的类型仍然不变。ServiceStack 隐式版本文档说明了版本属性的填充机制。

原示例中,两个请求都声明了 GET /projects,这也会带来路由问题。普通路由匹配不会根据 DTO 名称中的 V20231 或查询字符串中的 v=20231 自动选择新请求类型。查询字符串可以填充 DTO,但默认不属于路径匹配条件。详见 ServiceStack 路由文档。

推荐方案:只在破坏性变更时增加新契约

可以将软件发布版本与 API 契约版本分开管理:

  • 软件每半年发布,继续使用 2023.1.0、2023.2.0 等编号。
  • API 只有出现破坏性变更时才增加契约版本。
  • service pack 沿用原有 API 契约,不增加 DTO 后缀。
  • 服务端升级后,在承诺的支持期内继续提供旧契约。

API 契约可以继续用 20201、20231 命名。这个编号表示契约首次引入的版本,无需每次发布软件都复制一遍 DTO。

保留原有类型,为新契约使用不同路径

沿用已有的 Project、ProjectV20231、OrderByElement 和 ProjectsService,只调整请求定义及路由。假设 /api 是应用的部署前缀,[Route] 中就不再重复它。

[Route("/projects", "GET POST")]
[Route("/v20201/projects", "GET")]
public class GetProjects : IReturn<List<Project>>
{
    public string SearchCriteria { get; set; }
    public int PageSize { get; set; } = Constants.DefaultPageSize;
    public int PageNumber { get; set; } = Constants.DefaultPageNumber;
    public string OrderBy { get; set; }
}

[Route("/v20231/projects", "GET")]
public class GetProjectsV20231 : IReturn<List<ProjectV20231>>
{
    public string SearchTerm { get; set; }
    public int Offset { get; set; }
    public int Limit { get; set; }
    public List<OrderByElement> OrderBy { get; set; }
    public bool? Active { get; set; }
}
GET /api/projects
    → GetProjects
    → List<Project>

GET /api/v20201/projects
    → GetProjects
    → List<Project>

GET /api/v20231/projects
    → GetProjectsV20231
    → List<ProjectV20231>

旧客户端继续调用原地址,新客户端明确使用新契约:

List<Project> oldResult = client.Get(new GetProjects
{
    SearchCriteria = "example",
    PageNumber = 1,
    PageSize = 20
});

List<ProjectV20231> newResult = client.Get(new GetProjectsV20231
{
    SearchTerm = "example",
    Offset = 0,
    Limit = 20
});

DTO 和路由已经指定了契约,通常无需再设置 client.Version。

原示例的旧路由还声明了 POST,但展示的服务只有 Get 方法。如果旧接口实际支持 POST,应保留相应的 Post 或 Any 实现,只有路由声明还不够。

共用业务逻辑,分别维护兼容性转换

两个处理方法可以调用同一个应用服务,再分别转换输入和输出,无需为每个 API 版本复制全部业务代码:

旧请求 → 旧参数转换 → 应用服务 → 旧响应转换
新请求 → 新参数转换 → 应用服务 → 新响应转换

转换层需要保留原有语义。例如,旧接口按页查询,新接口使用 Offset、Limit。如果旧接口的页码从 1 开始,可以这样转换:

offset = (pageNumber - 1) * pageSize;

分页默认值、排序规则、空值处理和权限范围也必须保持原有行为。Description1、Description2 与 Description 之间如何合并或拆分,需要由业务规则定义,框架无法自行判断。

如果数据库升级后无法还原旧接口需要的数据,仅保留旧 DTO 也无法保证兼容。数据库迁移和业务实现需要一并处理这个问题。

原有 DTO 需要补上版本后缀吗?

不需要,可以继续使用:

GetProjects : IReturn<List<Project>>

在契约清单中记录这套类型是从 2020.1 开始提供的旧契约即可。

ServiceStack 不会解析 V20201 或 V20231 后缀来判断版本归属。后缀只是用来区分类型,方便维护人员理解。给原有类型改名可能影响共享 DTO 程序集、客户端代码重新生成,以及按请求类型名称访问的端点。

请求和响应也不必同时改名:

  • 请求结构发生破坏性变更、响应未变时,可以新增请求 DTO,继续返回原响应类型。
  • 响应结构发生破坏性变更时,为了明确表达新的 IReturn<T>,通常也应新增请求 DTO。
  • 即使 DTO 名称和字段都未变,只要行为发生破坏性变化,同样需要保留旧行为,或提供新契约。

给每个 response DTO 增加 Version 属性,无法建立请求与响应之间的类型关系。这种关系仍应由 IReturn<T> 等机制表达。

2023.2 没有破坏性变更时,如何复用 2023.1?

如果按契约版本访问,2023.2 发布后可以继续提供:

/api/v20231/projects

客户端无需更换 DTO 或修改版本号。

如果业务要求客户端始终使用软件发布版本路径,可以在 AppHost.Configure 中将新路径映射到已有请求类型:

Routes.Add<GetProjectsV20231>("/v20232/projects", "GET");

两个地址由此使用同一套契约:

/api/v20231/projects → GetProjectsV20231
/api/v20232/projects → GetProjectsV20231

应用需要明确注册这些映射,ServiceStack 不会自动查找“编号小于等于请求版本的最新 DTO”。

服务较多时,可以按操作维护映射清单:

客户端发布版本projects 使用的请求类型
20201GetProjects
20231GetProjectsV20231
20232GetProjectsV20231

只更新引入了新契约的操作的映射。不要将所有未来版本直接回退到当前最新契约。对于未知版本、已停止支持的版本,以及尚未引入某项操作的版本,都应明确规定何时拒绝请求。

如果必须使用同一路径加 ?v=,需要做什么?

需要自行实现版本选择规则。ServiceStack 提供了 [Route(..., Matches = "...")] 和 HostConfig.RequestRules,可以给路由添加自定义匹配条件。详见 自定义路由规则。

例如,让同一个 /projects 根据查询字符串中的版本匹配旧请求或新请求,需要完成以下工作:

  1. 解析版本,为未传版本的旧客户端保留明确的默认值。
  2. 检查版本是否受支持。
  3. 根据“操作名称+请求版本”选择契约。
  4. 在请求反序列化之前确定请求类型。
  5. 使用同一份契约映射生成文档和客户端 DTO。

如果两套请求结构不同,应在路由匹配或更早的请求处理阶段选择类型。普通服务请求过滤器通常在请求 DTO 创建后才执行,仅在过滤器中读取 Version 无法完成请求类型切换。

方式适用情况需要处理的内容
路径,如 /v20231/projects希望改动少、契约清楚注册路由或版本别名
查询参数,如 /projects?v=20231希望保持同一资源路径自定义版本选择;IHasVersion 本身只负责版本值绑定
header已有统一网关或协议规范自定义读取与选择逻辑,并配置相应缓存、跨域规则

对当前系统来说,路径方案的维护成本通常更低。如果同时接受路径、header 和 querystring,应规定版本冲突时如何处理,避免同一个请求携带两个不同版本。

即使通过 ?v= 成功选中了新接口,强类型客户端仍需使用符合新契约的 DTO。版本参数的变化无法让使用 GetProjects : IReturn<List<Project>> 的调用方自动得到 List<ProjectV20231>。

能否只展示指定版本的 API?

可以定制实现,但上述版本配置不会自动将 /api?v=20231 变成对应版本的 API Explorer。

接口调用、API Explorer、元数据、OpenAPI 文档和客户端 DTO 生成需要分别处理。只过滤页面上的操作列表,既不会改变请求分派,也不会限制对隐藏接口的访问。

较容易实施的做法是:

  • 为每个受支持的契约提供独立的文档入口和 DTO 包。
  • 用同一份版本映射清单确定文档包含哪些操作、路由和响应类型。
  • 保留已发布契约的文档快照,避免后续新增字段改变历史文档。

ServiceStack 支持用 [Tag] 为服务分组,也提供元数据页面过滤扩展点;客户端 DTO 生成支持 IncludeTypes。这些功能可以辅助制作版本文档,具体版本适用范围仍需由应用定义。详见 元数据页面定制、客户端类型筛选。

如果要求历史文档中的字段也完全一致,仅按当前 DTO 过滤服务列表还不够。例如,后来给同一个响应 DTO 增加了字段,当前元数据就会包含该字段。这时需要保留文档快照,或维护独立的历史契约类型。

可以借鉴 ASP.NET API Versioning 的版本读取、选择和文档分组思路,但其 MVC 或 Minimal API 集成不会自动管理 ServiceStack 的服务分派。即使 ServiceStack 托管在 ASP.NET Core 中,也需要针对实际端点模型完成集成。详见 ASP.NET API Versioning 功能说明。

如何判断变更是否兼容?

题目中列出的几项“非破坏性变更”,需要满足相应条件:

  • 新增 request 属性后,旧客户端不传该字段时仍须得到原有行为。新增必填校验会破坏兼容性。
  • 新增 response 属性时,要确认客户端能容忍未知字段;严格的 schema 校验可能拒绝响应。
  • 将无响应接口改成有响应接口时,应检查原有状态码、响应体约定和 IReturnVoid 调用方式,不能一概视为兼容。
  • 删除 request 属性即使不造成反序列化错误,也可能使原来的筛选条件失效,进而改变结果。

升级服务端前,应使用保留的旧客户端和请求样例验证旧契约,覆盖参数默认值、HTTP verbs、响应结构、错误响应及业务行为。在过渡期内公布旧契约的停止支持时间,并根据各契约的实际调用情况安排下线。

备注:内容仅供参考。