最近想学习写 API wrap,所以就对照 Github API 尝试写了起来。在扫过 Github API 文档之后,看到每一类 API 几乎都有 Create, Update, Delete, Get 这四种操作。所以当时脑中就想写出类似 rails model 中常使用的User.create
、User.find
、@user.update
、@user.delete
的这些方法。经过一些尝试之后最终失败了。现在看来失败最主要的原因其实是抽象不正确,我这里想实现的东西其实并不是 API wrap,其本质是一个映射系统。
在写这个文章的过程中,看到论坛之前帖子讨论到 Active Resource 其实实现的就是我这里要做的映射系统,所以我研究了相关的源码。所以下面我会将自己在写代码过程中遇到的问题与使用 Active Resource 结合起来。
为了方便后面叙述,这里需要先对 Active Resource 的部分内容作一个简要介绍。 首先,Active Resource 是为 REST web services 提供映射的,那么 API 的 URL 和 HTTP Verb 要遵守 REST 的约定。简单例子来说就是如下的形式:
GET /resources/:id
POST /resources
PATCH(PUT) /resources/:id
DELETE /resources/:id
不过现在 Active Resource 并不支持 PATCH,所以在 Active Resource 眼中,每一个资源都有如下的 API 接口:
GET /resources/:id
POST /resources
PUT /resources/:id
DELETE /resources/:id
我们首先来定义一个名为 resource
的资源
class Resource < ActiveResource::Base
site = "http://www.example.com"
end
对于上面的资源,我们进行查找操作Resource.find(1, params: { query: 1 })
会产生如下的 URL:
各个颜色在 Active Resource 代码中代表的含义如下:
prefix 的部分主要由 site
、prefix
、prefix=
、prefix_options
这些函数共同完成定义。prefix 除了前面例子中那么定义之外,我们还可以在 site 中添加相关的变量。例如:
class Repo < ActiveResource
site = "http://example.com/:user_id/:emoji"
end
通过上面的定义,我们如果要查找某个 repo,除了正常要提供的信息,还需要提供 user_id、emoji,所以相关查找如下:
# GET https://example.com/2/smile/1
Repo.find(1, params: { user_id: 2, emoji: "smile" }
element_path 与 collection_path 主要是根据自身定义的 class 的名称来生成。element_path 中的 id 值,主要由定义的 primary_key 的值给出。例如
class Resource < ActiveResource::Base
primary_key :name
end
resource.name = "name"
resource.id # => "name"
resource.name # => "name"
resource.element_path # => "/resources/name"
如果 resource 本身有 id 属性呢?比如上面的 resource 中有属性 id 的值为 1,那么我们如何设置和获取到这个值呢?在下面的讨论中,你会知道答案。
我们定义的每一个 resource 类对应的是 server 上的一类资源,每个资源都会有它的属性和相应的值
所有属性的名称和对应的值都存放在 resource 实例的 @attributes
这个变量中。
例如,我们根据如下 JSON 信息创建了一个 resource:
{
"id": 1296269,
"name": "Hello-World"
}
那么就可以得到
resource.attributes # => {"name"=>"Hello-World", "id"=>1296269}
而我们可以直接去读取和设置 name 和 id 的值,其实就是通过 method_missing
的黑魔法来实现的。
resource.name = "haha"
resource.name #=> "haha"
resource.attributes # => {"name"=>"haha", "id"=>1296269}
所以回到上一节的那个问题,如果我们设置的 primary_key 并不是 id,那么我们要取得 id 的值就可以通过 @attributes
来获得。
对于我们定义的每一个 resource,我们如何去确定它有哪些属性呢?Active Resource 中,相关属性可以通过 schema 去定义,也可以让代码通过获得的 JSON 信息去分析。
下面我举例来说明一下如何根据获得的 JSON 来定义属性的。假如我们获得 Repo 如下:
class Github::Repo < ActiveResource::Base
site = 'http://example.com'
end
而我们获得的 json 信息如下:
{
"id": 1296269,
"name": "Hello-World",
"owner": {
"login": "octocat",
"id": 1,
},
"forks": [
{
"id": 1296269
},
...
]
}
对于上面的 JSON,键值对中的值如果不是 Object 或者 Array,那么对应的键就成为 resource 的属性,而相应的值就是属性的值。所以通过上面的 JSON 信息,我们的 repo 信息如下:
repo.id # => 1296269
repo.name #=> "Hello-World"
JSON 中的值如果是 object 的话,那么 Active Resource 就会从现在的 namespace 开始到最顶层的 namespace 中去寻找我们是否定义了相关的类(例如上面的例子中,就会从 Github::Repo 这个 namespace 到 Github 再到最顶层的 namespace 中去查找我们是否定义了 Owner 这个类),如果没有定义相应的类,那么就在最底层的 namespace 中去定义一个这样的类(也就是会去定义一个 Github::Repo::Owner),而这个类的属性就重复一样的规则通过 object 的信息得到。也就是我们去查询 owner 时:
repo.owner.class # => Github::Repo::Owner
repo.owner.login # => "octocat"
JSON 中的值如果是 Array,Active Resource 会先检查这个属性是不是 association。在上面的例子中也就是会去看是否有下面的关系:
class Github::Repo <ActiveResource::Base
has_many :forks
end
如果定义了这种关系,就利用我们创建的 Fork 类去实例化 array 中的每一个 object。如果没有定义这种关系,同样的将名字变成单数后重复上面 object 的查找过程,然后就用查找到或者新创建的类去实例化 array 中的每个 object。
上面经过处理的信息,最终都保存在对象的 @attributes
属性中。
我这里讲述得可能不太清楚,更详细的了解大家可以去看源码中 load 这个函数。
在了解以上关于 Active Resource 的相关内容之后,我们进入相关正题。
这里先列出要实现该映射需要克服的困难
在具体探讨相关问题之前,我们在此明确我们要达到的目的,我们想要达到的主要是建立一个资源的基类,这个基类的作用就像 ActiveRecord::Base 一样,其他资源类通过继承这个基类,我们就能够不用做配置或者进行极少配置后,就能够通过完成基本的 CURD 操作。我们这里并不真的要将所有 Github API 全部涵盖进来,只是讨论是否能涵盖所有资源的 CRUD 操作。
明确完这一点后,我们开始讨论相关细节。
我们来看 Repo 和 Content 的相关 CURD 操作的接口:
# Repo 相关接口
POST /user/repos # => Create
GET /repos/:owner/:repo # => Get
PATCH /repos/:owner/:repo # => Edit
DELETE /repos/:owner/:repo # => Delete
# Content 相关接口
PUT /repos/:owner/:repo/contents/:path # => Create
GET /repos/:owner/:repo/contents/:path # => Get
PUT /repos/:owner/:repo/contents/:path # => Update
DELETE /repos/:owner/:repo/contents/:path # => Delete
通过 Repo 的 Create API 可以看到,它并不是我们所期望的 /repos
,而是前面有一些前缀。这个 URL 从它本身的意义来说,这样写是很正确的,因为 repo 是属于某个 user 的,而这个 user 也就是通过验证的用户,这个用户的身份信息也已经通过 token 进行了唯一定义,所以并不需要通过 /users/:id/repos
这样的 URL 来体现所属关系。
同样,在 Github 中每一个 Repo 是由 owner 与 repo 来定位,并不是用一个 id 来定位,所以在实现映射系统的时候需要允许 primary_key 由多个数据项来定义。
虽然如上所说,但是这却给我们实现映射关系设置了一道坎。
这里所说的 Verb 不规律只是对我们实现映射关系来说不规律,但是 Github API 本身使用 HTTP Verb 本身是非常严谨的。
根据 HTTP 标准中的说明,POST 与 PUT 方法最主要的区别就是在于对请求中 URI 的涵义解释的不同。对于 POST 方法来说,请求中 URI 指定的 resource 会去处理请求中所包含的这些 entity。但是 PUT 方法中 URI 是 identify 了请求中的 entity。我们来简单举一个例子:
# 名为 demo-user 的用户创建一个名为 demo 的 repo
POST /user/repos # { "name": "demo" }
# 然后服务器为我们创建了 demo 这个 repo它的位置为 /repos/demo-user/demo
# 我们要获取这个资源要访问的是
GET /repos/demo-user/demo
# 但是我们创建一个 content
PUT /repos/demo-user/demo/contents/demo
#当我们要访问这个资源的时候,我们用的还是同样的 URL
GET /repos/demo-user/demo/contents/demo
所以对于 POST 方法,URI 只是表明 URI 指向的 resource 会去处理这个请求中的 entity,但是并不表明这个 URI 指向的 resource 就是请求中的 entity。但是对于 PUT 方法来说,URI 指向的 resource 就是请求中 entity 会存放的位置,如果这个 entity 在其他位置,那么服务器就需要返回 301 进行重定向。(上面这些是我个人的理解,如有不正确的地方,请指正)
细心的人肯定发现了,Repo 中修改信息的 API 叫做 Edit,Content 中求改信息的 API 叫做 Update。在 Github API 中可以看到文档区分了 Edit 与 Update 的含义。
看到这里,我就去了解了一下 PUT 与 PATCH 的差别。我查到 PATCH 方法在 rfc5789 中进行补充定义的。该文档中说明了,PUT 方法虽然可以进行更新,但是它的含义是用新的信息对整个资源进行替换。而 PATCH 方法定义的是对资源进行部分的修改。
然后回到这里的 API,对于 Repo 来说,我们修改信息用 PATCH 是没有任何疑问的。而对于 Content 来说,为什么使用 PUT 呢? 我个人理解是,对于更新 content 来说,一次更新就会创建一个 commit,创建一个 commit 之后整个 repo 就更新了一个版本,也就可以理解为 content 的内容被新版本的内容完全替代了。
上面说了这么多 Github API 写得棒,但是对于我们想实现的东西来说,这些都是阻碍。
首先我们来看能否利用 Active Resource 提供的功能来解决 Repo API 相关的问题。 Repo API 主要有两个地方比较特殊:
/repos
owner
和 repo
两个值构成对于第一个问题和第三个问题,Active Resource 并没有相关办法去解决,因为这些代码是写死了的。 对于第二个问题,如果我们去看 Repo 的 API 文档,我们可以看到 API 返回的信息如下:
{
"id": 1296269,
"owner": {
"login": "octocat",
...
"site_admin": false
},
"name": "Hello-World",
"full_name": "octocat/Hello-World",
}
我们主要关注 full_name 这个属性的值,如果我们将 API 中 :owner/:repo
整个看做一个值,那么 full_name 正是我们要设置的 primary_key。所以我们可以如下创建 Repo。
class Repo < ActiveResource::Base
primary_key :full_name
end
repo = Repo.find("rails/rails") # => GET /repos/rails/rails
repo.id # => "rails/rails"
repo.delete => DELETE /repos/rails/rails
而对于 Content 的 API,除了无法实现 Create 方法之外,其他的操作通过定义下面的定义来实现:
class Content < ActiveResource::Base
site = "https://api.github.com/repos/:owner/:repo"
primary_key :path
end
通过上面的定义,我们就能进行相关操作:
# GET /repos/rails/rails/contents/readme
content = Content.find("readme", params: { owner: "rails", repo: "rails" }
content.message = "message"
content.save # => PUT /repos/rails/rails/contents/readme
content.delete #=> DELETE /repos/rails/rails/contents/readme
从 Github API 文档中我们可以看到,我们从 JSON 中或许的信息非常多,但是这些信息中只有部分是我们能修改的,例如 Repo 给我们返回的信息:
{
"id": 1296269,
"owner": {
"login": "octocat",
"id": 1,
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"type": "User",
"site_admin": false
},
"name": "Hello-World",
"full_name": "octocat/Hello-World",
"description": "This your first repo!",
"private": false,
"fork": false,
"url": "https://api.github.com/repos/octocat/Hello-World",
"html_url": "https://github.com/octocat/Hello-World",
"archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}",
...
"contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors",
"deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments",
"downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads",
...
}
但是 Repo 给出的 Edit API 接口中,我们能修改的信息只是获得的信息的一个子集。这与我们平时操作 Rails 中的 Model 就不一样了,对于 Rails 中的 Model,我们从数据库获得的所有模型的数据都是可以进行编辑的。
所以在我们实现映射功能的时候,我们有三个选择:
ruby
# 我们可以对本身不能修改的数据进行修改
repo.private = true
# 然后将以上的所有数据交给服务器
repo.save # { id: "id": 1296269 .... fork: false, private: true ...}
ruby
# 我们可以对本身不能修改的数据进行修改
repo.private = true
repo.save # { name: "name", descption: "desc"}
ruby
repo.private = true # => Error!
#### Active Resource 处理相关问题的方法
利用 Active Resource 我们能轻松的实现上面的 1、2 解决方法。如果要实现 3 这种解决方法,需要额外打一些补丁。对于 1 方法来说,Active Resource 本身代码就是这样实现的,所以不需要额外工作。
对于 2 方法来说,我们需要在我们定义的每一个 resource 中定义如下的方法:
alias :old_encode :encode
def encode(options = nil)
old_encode(options || { only: [...] }
end
only 后面的 array 中就是定义的我们所允许传给服务器的相关数据。
Github API 除了支持 JSON 外,还可以支持一些其他的格式。
Active Resource 目前只支持 JSON 和 XML,但是由于 Active Resource 的代码组织的非常好,我们只需要在 ActiveResource::Formats 的 namespace 中定义想要的 MIME type,并给 ActiveResource::Formats 模块打上补丁,用于载入新定义的格式。相关实现细节,可以参考 Active Resourse 中已实现的功能。
当然说到 MIME type 这里,大家可能注意到 Active Resource 发出的请求中,会在 URL 中加入 MIME typed 的格式扩展名。但是 Github API 并不支持扩展名。我们可以通过在 Resource 类中进行如下设置来去掉这个扩展名:
class Resource < ActiveResource::Base
self.include_format_in_path = false
end
在使用 Rails Model 的时候,我们很熟悉对于 where
、all
会延迟查询。对于一般find
查询操作,由于数据库执行很快,所以不用做相关优化。
但是对于通过 Web API 实现的映射系统来说,由于网络传输的开销很大,所以对于每一个查询能做优化,能延迟查询都很重要的。例如:
Resource.find(1).delete
对于目前来说,上面的代码会经历两个 HTTP 响应:
Resource.find(1) # => GET /resources/1
.delete # => DELETE /resource/1
但是从我们要达到的目的来说,其实只要执行最后一个 HTTP 请求就可以了。
当然优化也存在一些困难需要克服,例如:
repo.delete.update(name: "f")
对于第二行的两个操作发起两个 HTTP 请求是不能简化的。从这个例子来看,貌似只要遇到非 GET 请求就不能优化了,但是如果把上面两个操作反过来呢?
repo.update(name: "haha").delete
这种情况可以进行优化,但是有的情况也不能优化。比如 update 不仅仅是更新 name 的信息,而是有其他的副作用的话,那么这两个操作就不能够优化。
Active Resource 目前没有做这些方面的工作,只有遇到需要发出请求的操作,Active Resource 就会立即发出请求。
假设我们客服了上面的所有问题。我们得到什么东西呢?我们得到的是 Repo、Branch 等等一堆类,其它人要去使用的时候,那么就需要先了解有哪些类,然后根据需要调用相关的类去完成功能。假设大家能够接受这样的东西,那么我们使用这些类的时候还有一个问题就是,GIthub 有些 API 是需要验证才能使用的。不过这个问题也好解决,Active Resource 提供了 Basic authentication,可以用于验证:
Repo.user = "rails"
Repo.password = "password"
Repo.find(1) # => 请求会携带用户名和密码
但是如果我们要在代码中要同时访问两个 user 的 repo 呢?那这样就需要不停的更换 Repo 类的 user 和 password,这明显很不方便,而且转换次数多了还容易引起混乱。所以这也最终决定了这种形式不能成为一个 API wrap。
这个文章我回头看一下,好像对大家的价值并没有多少,可能最大的价值就是帮助大家了解一下 Active Resource 吧。