频道
bg

RESTful API Versioning

coding十二月 18, 20141mins
RESTAPI

关于RESTful API Versioning,有很多讨论 Nobody Understands REST or HTTP, Versioning REST Services, best-practices-for-api-versioning, How are REST APIs versioned?, 总结如下。

为什么需要VersioningH1

尽管设计API的时候,我们尽可能设计完美的API,尽可能的避免修改API。但是随着业务需求的变更,API接口的变化几乎是无法避免的。

当业务需求变更的时候,可以这样选择:

  1. 保持接口的兼容性。这是一种方式,但是并非能切实做到的,为了兼容必定会损失一些新特性,或者牺牲良好的代码为代价。
  2. 修改API接口的同时,修改客户端。除非自己维护少量的客户端,否则这几乎是不现实的。
  3. 保留旧的接口,通过版本来实现API接口的变更。

通常情况下,我们会选择第三种方式来实现API接口的变更。

实现策略H1

  1. URI

    • https://api-v1.example.com/places
    • https://api.example.com/v1/places

    上述两种方式都是分别通过Path和Hostname来进行versioning。这种方式的好处直观,友好,易于理解,“复制&粘贴”更为友好;但是RESTful本身就不是“复制&粘贴”友好的。RESTafarian们根本不认同这是RESTful API,因为它破坏了HATEOAS,直接称它为TUK(The URL is King)。

  2. 请求体,Query参数 POST/placesHTTP/1.1 Host:api.example.com Content-Type:application/json

    bash

    {
    "version" : "1.0" 7
    }

    如果客户端的请求是JSON格式的,实现起来倒是不难。如果是Content-Type是image/png 或者是text/csv呢,这就不好处理。

    或者把参数放到Query参数里: POST/places?version=1.0HTTP/1.1 这种情况下,如果是POST请求呢,一些框架在POST请求的时候会直接忽略GET参数,虽然这有悖于HTTP协议,但是在POST请求带入GET参数,还是让人非常困惑的。

  3. 自定义请求头 GET/placesHTTP/1.1 Host:api.example.com BadApiVersion:1.0

    看起来好像挺好的,但是这绝对代码中的坏味道。为了让缓存系统能够正确低返回果,我们Response必须是这个样子的:

    bash

    HTTP/1.1200OK
    BadAPIVersion:1.1
    Vary:BadAPIVersion

    如果不指定Vary,像varnish缓存系统是不知道如何缓存这样的请求的。另外,抛开这点不说,要知道这个HTTP还必须通过查阅文档才能了解,这也挺恼人的。关于Vary头,请参考6

  4. Content Negotiation 这种方式是符合HATEOAS,也是相对最优雅的方式。Github API的Versioning就是通过这种方式实现的。

    bash

    Accept:application/vnd.github.user.v4+json
    Accept:application/vnd.github.user+json;version=4.0

    这种方式,对HATEOAS和缓存都非常友好。唯一可能有点麻烦的就是实现, 主流的框架都没有处理自动根据请求的Content-Type处理的机制。

代码实现H1

  1. 对于URI的实现策略,最直接有效的实现方式就是通过多个代码库实现API的多个版本。部署时候把不同的版本部署到不同的server上。像这样:

    • 1.0/master
    • 1.0/develop
    • 2.0/master
    • 2.0/develop

    有一个非常好的Git Flow可以参考。

  2. 对于Content Negotiation需要同一个代码库的实现,这个是重点。

    以Rails为例。 Rails本身有一个很好的Gem叫做versionist实现了这个功能。 为了更好的理解其实现,我们可以自己实现以下。

首先是路由,给v1版本设置namespaceH2

/config/routes.rb

bash

Store::Application.routes.draw do
namespace :api do
namespace :v1 do
resources :products
end
end
resources :products
root to: 'products#index'
end

接着是v1的实现H2

/app/controllers/api/v1/products_controller.rb

bash

module Api
module V1
class ProductsController < ApplicationController
respond_to :json
def index
respond_with Product.all
end
def show
respond_with Product.find(params[:id])
end
def create
respond_with Product.create(params[:product])
end
def update
respond_with Product.update(params[:id], params[:products])
end
def destroy
respond_with Product.destroy(params[:id])
end
end
end
end

然后,v2版本需要变更这个APIH2

数据库变更H3

/config/db/migrations/201205230000_change_products_released_on.rb

bash

class ChangeProductsReleasedOn < ActiveRecord::Migration
def up
rename_column :products, :released_on, :released_at
change_column :products, :released_at, :datetime
end
def down
change_column :products, :released_at, :date
rename_column :products, :released_at, :released_on
end
end

这样V2版本已经能正确返回的修改后的结果了。

保留V1H3

bash

module Api
module V1
class ProductsController < ApplicationController
class Product < ::Product
def as_json(options={})
super.merge(released_on: released_at.to_date)
end
end
respond_to :json
# Actions omitted
end
end
end

这里两个版本的ProductsController有很多重复代码,不太符合DRY原则。旧版本的代码的保留通常了为了兼容,总有那么一天旧版本的API废止了,那么重构就不值得了。如果确实觉得需要,可以把共通的行为提取到超类里面。

完整示例

评论


新的评论

匹配您的Gravatar头像

Joen Yu

@2022 JoenYu, all rights reserved. Made with love.