框架是否跟得上现代的API要求?

239 阅读10分钟

多年来,我使用了大约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):

这些都是不可知的工具,需要一些模板才能在大多数框架中运行,但我认为框架应该与这些工具进行整合,或者提供相应的功能。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 ProblemsJSON: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合同:

当你要求开发者重复做同样的事情时,有些事情的质量就会很低。相反,在过去的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-SinceIf-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支持的需求现在比以往任何时候都更加重要。