多年来,我使用了大约20种不同的网络应用程序框架(制作服务器端应用程序的工具包),为一些框架做出了贡献,并维护了一些流行的框架。我有一个理论,许多开发者在他们的框架提供给他们的时候被引入到新的方法论。
虽然这听起来像货物崇拜的文化,但我明白,开发者是很忙的人。显示如何进行DDD、TDD、Event Sourcing或其他什么的用户手册,在你已经熟悉的框架的背景下更容易理解。
这些天来,许多框架要么主要是为创建API而设计的,要么有一个 "API模式",以减少与API无关的引导,如cookies、会话、视图等。
我们都知道技术发展很快,但是API开发的世界已经发生了巨大的变化,而且也很快。许多Web应用框架从来没有很好地解决过HTTP/1.1时代的API问题,并且把大量的工作强加给应用开发者,而这些工作本来可以通过一些内置的便利方法轻松解决。随着HTTP/2的成熟和广泛使用,以及HTTP/3即将问世,他们可以做很多事情来帮助。有些人仍然没有把HTTP/1.1覆盖得特别好。
例如,早在2009年,我发布了CodeIgniter REST,现在由Chris Kacerguis维护。那时候我对REST是什么是错误的。我误解了Richardson成熟度模型,以为它是HTTP上的JSON。我把它加到了一个框架中,而这个框架在处理HTTP方面令人惊讶地非常糟糕:没有查询字符串支持,只支持GET/POST,等等。
如今,几乎所有的框架都默认支持 "HTTP路由"、查询字符串、JSON等,如果它们不支持,那就太令人震惊了。在那之前,每个人都是自己黑进去的,但可悲的是,API开发者仍然不得不黑进去支持很多这些基本概念。
让我们来看看其中的一些东西。也许你的框架可以做到这一点,也许你会受到启发,向他们发送一个拉动请求。😎
序列化
当涉及到输出时,许多框架似乎为你提供了一个JSON助手,但没有做更多的事情。创建API响应不应该仅仅是 "我有一个对象,现在它是一个JSON对象",因为这就是你建立脆弱的垃圾API的方式,它只是把DB模型甩给消费者。
序列化器很重要,它本质上是资源的一个 "视图"。他们让你选择哪些字段应该被输出, 组合字段, 计算额外的字段(并不总是很好), 甚至可能将一些数字枚举值转换成人类可读的标签.
Laravel有内置的序列化逻辑,而Symfony也有一个组件。
最好的序列化器更进一步,提供对常见 "消息格式 "的支持,如HAL,JSON:API,JSON-LD,或Siren。消息格式不仅仅是JSON,而且对集合的形状、资源、元数据、错误信息以及状态变化、分页等方面的链接进行标准化。这使开发者不必再为小事骑自行车而做出错误的决定,也意味着可以跳过《构建你不会讨厌的API》中的整个章节。
一个关心API的框架不需要支持每一种消息格式,但是序列化器应该是可扩展的,所以不同的格式可以被插入。一个好的API应该能够很容易地支持多种格式,而目前我所知道的任何框架都无法做到这一点。
对于许多框架来说,弄清楚使用哪种序列化器是很困难的。有些序列化器速度快但只支持一种格式(如fast_jsonapi),有些支持多种格式但速度慢(如ActiveModel序列化器),有些已经年久失修了(同样是AMS):
- 用于PHP的Fractal
- 用于Ruby的Roar
- Python的Marshmallow
这些都是不可知的工具,需要一些模板才能在大多数框架中运行,但我认为框架应该与这些工具进行整合,或者提供相应的功能。API平台提供了JSON-LD和HAL与它的序列化器。
标准错误
很多API会发出HTML错误信息,这让API消费者感到困惑;他们只看到 "Invalid JSON token <",而没有进一步的解释,直到他们挖掘出网络流量......
其余大多数为错误输出JSON的框架会继续前进并发明他们自己的rando-JSON错误格式,或者期望用户创建他们自己的格式:
{
"ErrorCode": "CATALOG_NO_RESULT",
"Description": "Item does not exist"
}
无论他们如何吐出内部错误,框架应该提供高质量的错误序列化器,这样用户就不会发现自己在倾倒return json({ error: "something went wrong" }) 。你曾与多少个API合作过,这些API被扔给了你?
{
"error": "something went wrong"
}
随着时间的推移,这些开发人员意识到他们需要程序化的代码、更长的解释、问题的链接,以及其他各种东西。创建高质量的错误信息并不容易,而框架应该通过支持常见的错误格式来支持这一点:
config.error_format = "rfc7807"
config.error_format = "json:api"
config.error_format = do |errors|
errors.map do
# ... some custom shit
end
end
支持RFC 7807: API Problems和JSON:API Errors意味着每个API响应的错误输出都是一致的,增加了该组织的API组合中出现一致错误的机会,并为API消费者消除了大量的猜测。
反序列化
我建立Fractal的目的是为了序列化,每当有人问为什么它不能反序列化时,我都会回答说序列化和反序列化是两项不同的工作。我在很多事情上都是错的,这一点也可以列入清单。序列化和反序列化共享一个 "契约 "的概念(即构成相关资源的所有属性),将它们从资源中转化为符合消息格式的JSON与读取符合消息格式的JSON并将其转化为资源一样重要。
例如,在Rails中,ActiveModel Serializer会给你所有的帮助来输出JSON:API,但当你试图读回这些数据时,你就会陷入与Strong Parameters的纠缠之中:
params.require(:data)
.require(:attributes)
.permit(:title, :description)
资源越大,这种语法就越复杂,特别是当你在有效载荷中有一个对象数组,其中一些对象的属性是必需的,而另一些则不是......
Trailblazer是一个宝石的集合,它使基于Ruby的框架更好地成为一个API框架,并通过共享合约提供序列化和反序列化。它还附带了对流行的消息格式的支持。这里和那里的文档有一些差距,但你通常可以从挖掘中找到你需要的东西。
Google有一个Golang JSON:API序列化/反序列化器,它使用注释的结构:
type Blog struct {
ID int `jsonapi:"primary,blogs"`
Title string `jsonapi:"attr,title"`
Posts []*Post `jsonapi:"relation,posts"`
CurrentPost *Post `jsonapi:"relation,current_post"`
CurrentPostID int `jsonapi:"attr,current_post_id"`
CreatedAt time.Time `jsonapi:"attr,created_at"`
ViewCount int `jsonapi:"attr,view_count"`
}
type Post struct {
ID int `jsonapi:"primary,posts"`
BlogID int `jsonapi:"attr,blog_id"`
Title string `jsonapi:"attr,title"`
Body string `jsonapi:"attr,body"`
Comments []*Comment `jsonapi:"relation,comments"`
}
type Comment struct {
ID int `jsonapi:"primary,comments"`
PostID int `jsonapi:"attr,post_id"`
Body string `jsonapi:"attr,body"`
Likes uint `jsonapi:"attr,likes-count,omitempty"`
}
如果有更多的人使用现有的标准,那么我所做的许多培训和写作就会被抛弃,如果他们的框架能让他们更容易地使用这些标准,那么就会有更多的人使用它们。那些正在使用这些标准的人也会欣赏他们代码的简化,所以这对所有人来说都是双赢。
验证
在一些框架中,反序列化和验证经常被混淆在一起。API中的反序列化只是将电线上的内容变成可以理解的东西,然后验证逻辑检查数据是否良好。试图在同一个接口中做这两件事是很粗糙的,而且很少有好结果。
验证逻辑并不想了解JSON:API,而试图将 "这个字段应该与这个重码相匹配 "和 "在数据库中也应该是唯一的 "塞进反序列化逻辑,只会造成一个大的混乱。
用另一个Rails的例子来说,Strong Params是用于反序列化的,那么验证通常发生在模型中。模型是验证的一个棘手的地方,关于这个问题,请搜索 "Skinny Model",并注意有多少关于这个话题的文章。
一些工具的解决方案是制作一个验证合同文件,它只是进行验证:
class NewUserContract < Dry::Validation::Contract
params do
required(:email).filled(:string)
required(:age).value(:integer)
end
rule(:email) do
unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
key.failure('has invalid format')
end
end
rule(:age) do
key.failure('must be greater than 18') if value < 18
end
end
这就把它移出了模型或控制器(所有这些都有其他文章中的优点和缺点),但这又是一个人们不得不写出合同的地方。API开发者已经在以下地方维护了API合同:
- (去)序列化器
- HTTP客户端(如Postman)
- 契约测试
- 通过OpenAPI的文档
当你要求开发者重复做同样的事情时,有些事情的质量就会很低。相反,在过去的18个月里,我一直在试图找出如何减少API开发者的重复工作量。
最后一个,OpenAPI...看起来有点像这样:
type: object
properties:
username:
type: string
email:
type: string
format: email
age:
type: integer
minimum: 18
required:
- email
- age
这是一个可用的合同,可以完全减少需要写出那个验证合同的要求。只要拿着OpenAPI文件,在服务器端进行验证。如果错误格式是RFC 7807或JSON:API错误,那么不管错误是来自OpenAPI验证器,还是来自控制器,或者是一些基于数据库的唯一性异常被抛出的竞赛条件,它看起来都是一样的。
一个框架可以很容易地建立序列化器和反序列化器的逻辑,这个合同可以是他们通常的DSL,也可以是一个模式对象,以减少类型化的数量。我写过关于API开发者已经可以使用OpenAPI进行服务器端验证的文章,但这不应该是人们需要砍掉的东西。
任何曾经认为编写OpenAPI很困难的人,可能在Stoplight Studio出现之前就在做这件事了。我们使API设计优先不仅是可能的,而且是令人难以置信的简单。随着越来越多的人开始在写代码之前创建API描述,框架可以利用这种描述作为合同,帮助人们更快地运行代码。对于API开发者来说,设计优先的OpenAPI是最重要的,它应该作为任何API框架的第一类部分得到支持。
HTTP/2+服务器推送
在HTTP/1.1的旧API中,最大的根本变化之一是对 "大量的HTTP调用 "的担忧,导致API开发者将大量的JSON混在一起。
一些API格式和架构,包括GraphQL、JSON:API和HAL,试图通过允许客户端要求服务器在主要的HTTP响应中嵌入相关资源来解决获取不足的问题。基本上,服务器将创建一个大的JSON文档,其中包含主要请求的资源和所有请求的关系作为嵌套对象。这个解决方案旨在限制客户端和服务器之间昂贵的往返,并且(几乎)是使用HTTP/1时唯一有效的解决方案。 然而,这个黑客引入了不必要的复杂性并伤害了HTTP缓存机制。而且,有了HTTP/2,它就没有必要了! -Kévin Dunglas,Vulcain。
尽管HTTP/2是在2015年发布的,但大多数框架只支持最基本的一点:复用。框架不需要支持复用,这是一个由客户端、Web服务器(nginx)、应用服务器(rack、unicorn等)启用的网络级功能。
多路复用对API设计有很大的改变,限制了获取不足的成本,但对API设计和开发来说,更有趣的HTTP/2功能是服务器推送。
服务器推送让API开发者解决过度获取的问题,让他们设计更简单的资源,并将相关内容转移到其他端点,如果客户端可能想要的话(或者他们用Preload request header特别要求的话),可以先期推送给客户端。

在HTTP/1.1时代,API开发者会在一个响应中包含所有切身相关的资源,以避免客户不得不进行多次请求,而这对于不需要额外数据的客户来说是很慢很烦人的。如果其中一些数据是经过计算的,或者是高度不稳定的,因此很容易缓存,那么整个资源每次都会很慢,而不是分成一些可缓存和一些不可缓存的资源。
例如,在我以前的雇主那里,/users/fred 资源包含了他们所有的地域信息、各种公共档案、他们工作的公司信息、订阅信息、他们可以访问的建筑以及关于这些建筑的其他信息。订阅和建筑信息都是从其他API获取的,而这些API有一半的时间是停机的,即使它们是正常的,也很慢,这影响了这个API的性能,而且......那个公司本身就是一本书......。
总之,每次有新的客户要求提供新的信息时,这些都被塞在那里,因为 "aggghhh multiple requests!😱",但有了服务器推送,建筑物、订阅、地域等都是他们自己的资源。客户端仍然会对相同的相关资源提出请求,但由于服务器推送,这些响应会在客户想到要求它们之前就被放在客户端推送缓存中。
Rails通过在v5.2中添加Early Hints来实现对HTTP/2的支持,这似乎是向服务器推送迈出的一步,但此后没有进展。Ruby的情况很棘手,因为它后来通过Rack这样的项目变得具有网络意识,现在一切都依赖于Rack。Rack还不支持HTTP/2。尽管有Falcon这个支持HTTP/2的Rack兼容应用服务器存在,Rails仍然没有服务器推送......
func handle(w http.ResponseWriter, r *http.Request) {
// Log the request protocol
log.Printf("Got connection: %s", r.Proto)
// Handle 2nd request, must be before push to prevent recursive calls.
// Don't worry - Go protect us from recursive push by panicking.
if r.URL.Path == "/2nd" {
log.Println("Handling 2nd")
w.Write([]byte("Hello Again!"))
return
}
// Handle 1st request
log.Println("Handling 1st")
// Server push must be before response body is being written.
// In order to check if the connection supports push, we should use
// a type-assertion on the response writer.
// If the connection does not support server push, or that the push
// fails we just ignore it - server pushes are only here to improve
// the performance for HTTP/2 clients.
pusher, ok := w.(http.Pusher)
if !ok {
log.Println("Can't push to client")
} else {
err := pusher.Push("/2nd", nil)
if err != nil {
log.Printf("Failed push: %v", err)
}
}
// Send response body
w.Write([]byte("Hello"))
}
NodeJS也有服务器推送支持:
const http2 = require('http2')
const server = http2.createSecureServer(
{ cert, key },
onRequest
)
function push (stream, filePath) {
const { file, headers } = getFile(filePath)
const pushHeaders = { [HTTP2_HEADER_PATH]: filePath }
stream.pushStream(pushHeaders, (pushStream) => {
pushStream.respondWithFD(file, headers)
})
}
function onRequest (req, res) {
// Push files with index.html
if (reqPath === '/index.html') {
push(res.stream, 'bundle1.js')
push(res.stream, 'bundle2.js')
}
// Serve file
res.stream.respondWithFD(file.fileDescriptor, file.headers)
}
Go和NodeJS的框架已经为他们做了很多事情,所以他们只需要让这种推送更容易一些,也许可以添加一些辅助方法,并将其与他们的序列化逻辑挂钩。
网络缓存
网络缓存是一个复杂的话题,Ruby on Rails已经设法使其变得非常容易。首先,告诉API做一个基于时间的过期是非常简单的。下面是Heroku的文章*HTTP Caching in Ruby with Rails中的*一个例子。
def show
@company = Company.find(params[:id])
expires_in 3.minutes, public: true
# ...
end
添加这个将发出一个Cache-Control ,并将max-age ,并将其设置为180。

基本的基于时间的缓存在这里并不令人兴奋,最好的一点是stale?
def show
@company = Company.find(params[:id])
if stale?(etag: @company, last_modified: @company.updated_at)
@company.employees = really_expensive_call(@company)
# ...
end
end
这里的stale? 方法是检查条件头,如If-Modified-Since 和If-Match ,这有助于避免写入时的竞争条件,并能为读取提供缓存。
这是一个粗略的用法,适用于任何东西,公司可以是任何种类的实例,但如果它恰好是一个ActiveRecord实例(或足够类似的东西),它可以简化为if stale? @company 。
这些东西也可以被组合起来:
def show
@company = Company.find(params[:id])
expires_in(3.minutes, public: true)
if stale?(@company, public: true)
# …
end
end
如果一个网络API没有内置任何缓存助手,那么它对API开发者来说就不是特别有用,不管他们是在构建一个HTTP API还是RESTful API。
总结
当我在寻找一个框架时,我看我能完成多少,因为我构建的任何API都需要HTTP缓存、HTTP/2与服务器推送、序列化、反序列化、基于OpenAPI的验证、标准错误格式,以及对JSON:API等消息格式的支持。如果周围没有解决这些问题的第一方或第三方工具,我将会推荐另一个解决这些问题的框架。
不是每个人都像我一样花那么多时间考虑API开发的解决方案,我明白这一点。许多开发者可能没有听说过其中的一些概念。其他开发者可能很熟悉,但不确定如何轻松实现它们。有些开发者可能不想介绍一个先进的概念,而他们经验不足的同事会感到很困难。这就是框架一直以来的优势所在,它可以帮助经验不足的人用简单的、有据可查的界面来做更高级的事情。
也许我们会看到更多针对API的框架出现,而更多通用的网络应用程序框架则继续它们的方式。也就是说,随着API优先开发的趋势越来越强,越来越多的后端开发人员转而主要进行API开发。我觉得对通用框架的需求在某种程度上正在下降,这意味着对适当的API支持的需求现在比以往任何时候都更加重要。