内部系统的 API 响应和异常实践

背景

Web 开发中前后端分离的一大阻碍是交互的数据结构复杂难用,离服务端直接渲染那样简单和灵活相差甚远。另外很多项目没考虑自身场景的滥用了 API 规范,比如内部的后台系统,经常被“规范”束缚强制统一响应结构,将 4xx 甚至 5xx 异常全部改成 2xx 响应,然后自己定义一套复杂的异常规范。

对于内部后台系统,这种简单场景下的 API 响应和异常处理,其实设计可以考虑这些:

  • 完全用 HTTP Status 规范,不另起 code。
  • 2xx, 4xx, 5xx 分开处理,基本所有 HTTP Client 都能很好的区分他们,不需要强制统一起来(后面会讲分开的优点)。
  • 正常响应情况,只要统一用一个规范的响应体即可,这里不难。对于任何异常情况,后端统一用异常处理,这样可以统一到基类一起处理响应,业务代码中也可以快速 return(少写很多 else)。另外可以在基类声明一个 ClientError 异常,出错时不清楚该用什么异常就都用这个。

后面我们基于这些,详细设计出一种方案,并给出实践例子。

四种响应和异常

首先,我们需要为响应和异常分类,这里根据场景频率对异常和响应分类,对于内部系统,大部分情况是正确响应和只需要一个错误信息的异常响应,这里将他们分别归类到“2xx响应”和“4xx响应”。剩下情况是服务器异常和复杂业务异常响应,这里将它们分别归类到“5xx响应”和“6xx+响应”。

根据二八原则,常用的前两者应该尽可能做到使用简单,因此使用方式要区别开后面两者。不常见的“6xx+响应”为了保证功能强大允许使用起来复杂一点(反正用的少)。

2xx 响应 - 正常情况

这个比较简单,也很多规范,只要约束住 HTTP Status 为 2xx 和结构体就行

- status: 2xx
- message: 附带消息,比如成功提示
- data:功能主数据
- meta:功能附带数据,比如分页筛选等信息

4xx 响应 - 只需要一个错误信息的异常情况

- status: 4xx,常用错误,后台系统一般直接抛除一个 ClientError 并传入一个错误信息即可。
- message: 附带消息,前端统一处理所有这类异常,消息提示参考:`API Client Error: ${message}`

5xx 响应 - 服务器异常情况

完全不处理,这样能最大限度保留后端堆栈等异常。前端也统一处理所有这类异常,消息提示参考:

`API Server Error: ${statusText}`

6xx+ 响应 - 复杂业务异常情况

- status: 6xx+,这是不常用的错误,因此用法允许复杂一点,可以配合配置文件中使用,方便汇总所有业务错误,+的意思是还可以用 7xx, 8xx(HTTP Status 标准中,大于 600 都属于 Custom Status)。
- message:附带信息,一般是一个总结性的错误描述,也可以放到配置文件里。
- detail:指引前端如何进一步处理的主数据,要有这个,才需要用这类响应,否则应该使用简单的 4xx 响应。

实践例子

下面代码以后端 Rails 5,前端 Vue.js 2 举例:

1、后端路由定义和功能处理,功能为了简单,放到一个 controller 中,根据不同参数处理四种响应。这里为了简单,放到一起了,实际上这四种场景会遍布在你的项目的每个功能里:

# config/routes.rb

get '/test_api/:status', to: 'test_api#index'
# app/controller/api/test_api_controller.rb

class Api::TestApiController < Api::ApplicationController
  def index
    case params[:status]
    when '2xx'
      render_data '我是正常响应数据'
    when '4xx'
      raise ClientError.new('我是一个 4xx 错误') # 这里常用异常处理很简单,使用起来跟后端渲染中的设置 flash 错误差不多简单了
    when '5xx'
      undefined_method # 这是一个没有定义的方法或变量,这里目的是手动制造一个 5xx 异常
    when '6xx'
      raise CustomError.new(:order_create_failed, '我是业务错误 detail 信息')
    else
      raise ActionController::BadRequest, '没找到该 status 处理方式'
    end
  end
end

2、后端基类定义,这个是后端最重要的地方,包括三种响应的统一处理(5xx 应用不处理):

# app/controller/api/application_controller.rb

class Api::ApplicationController < ActionController::API
  class ClientError < StandardError
    attr_accessor :status, :message

    def initialize(message, status = 400)
      @status, @message = status, message
    end
  end

  class CustomError < StandardError
    attr_accessor :status, :message, :detail

    def initialize(name, detail = {})
      error = Rails.configuration.custom_errors.find { |error| error['name'] == name.to_s }
      @status, @message, @detail = error['status'], error['message'], detail
    end
  end

  # 4xx 响应
  rescue_from ClientError do |exception|
    render status: exception.status, json: { message: exception.message }
  end

  # 6xx+ 响应
  rescue_from CustomError do |exception|
    render status: exception.status, json: { message: exception.message, detail: exception.detail }
  end

  # 2xx 响应
  def render_data(data, status: 200, message: "", meta: {})
    render status: status, json: { message: message, data: data, meta: meta }
  end
end

3、后端 CustomError 配置,汇总在一起使得错误信息更清晰:

# config/initializers/config_custom_errors.rb

file_dir = Rails.root.join('config', 'custom_errors.yml')
Rails.configuration.custom_errors = YAML.load_file(file_dir)
# config/custom_errors.yml

- status: 600
  name: order_create_failed
  message: 订单创建失败
- status: 601
  name: order_cancel_failed
  message: 订单取消失败

4、前端路由定义和功能处理:

// config/routes/index.js

{
  path: '/test_api/:status',
  component: () => import('@/pages/test_api'),
}
// pages/test_api/index.vue

<template>
  <div>
    <div>{{ result }}</div>
    <div v-if="$flash">{{ $flash }}</div>
  </div>
</template>

<script>
  import request from '@/utils/request'

  export default {
    data () {
      return {
        result: '',
      }
    },
    beforeMount () {
      $flash = ''
    },
    mounted () {
      request
        .get(`/test_api/${this.$route.params.status}`)
        .then(data => {
          this.result = data
        })
        .catch(error => {
          // 只有功能有 6xx+ 响应时才需要 catch,普通功能不必写这个
          this.result = `我是一个业务错误,${JSON.stringify(error.response.data)}${error.response.status}`
          const detail = error.response.data.detail
          if (error.response.status === 600) {
            // 做一些 600 异常处理的事情,可以使用后端传过来的 detail
          } else if (error.response.status === 601) {
            // 做一些 601 异常处理的事情,可以使用后端传过来的 detail
          }
        })
    },
  }
</script>

5、前端请求统一处理处,这个是前端最重要的地方:

// utils/request.js

import axios from 'axios'

const request = axios.create()
request.interceptors.request.use(
  config => config,
  // 请求发出失败处理,比如无网络
  error => {
    $flash = error.message
    // 这个是一个永远 pending 的 Promise,可以阻止 Promise 持续传播到 then 中
    // https://juejin.cn/post/6935404767778701325
    return Promise(() => {}) 
  },
)
request.interceptors.response.use(
  response => response.data,
  error => {
    // 无响应处理,比如 timeout
    if (!error.response) {
      $flash = `API Noresponse Error: ${error.message}`
      return Promise(() => {}) // 返回一个 pending 的 Promise,防止执行功能的 then 方法
    // 4xx 响应处理
    } else if (error.response.status >= 400 && error.response.status <= 499) {
      $flash = `API Client Error: ${error.response.data.message || error.response.status}`  // 当 4xx 错误是由 Web 服务器或者应用服务器触发的,此时没有 message 值,这里要特殊处理一下
      return Promise(() => {})
    // 5xx 响应处理
    } else if (error.response.status >= 500 && error.response.status <= 599) {
      $flash = `API Server Error: ${error.response.status}`
      return Promise(() => {})
    // 6xx+ 响应处理
    } else {
      return Promise.reject(error) // 不处理,直接抛异常给业务层处理
    }
  },
)

export default request

这里 $flash 只是一个简略的全局变量(这个处理不是重点,因此没有扩展),实际应用中可以实现具体的响应处理。

这样就大功告成了。

其他

以上是一个思路,具体应用到项目中,可以在添加很多规范约束上的东西,比如考虑同伴如果不使用规范怎么拒绝(这个其实可以参照 Rails 对重复设置 response 或者没有设置 response 的处理)。

API 响应和异常处理属于很常见的技术问题了,以上是我的一些浅显看法,欢迎大家发表任何想法,一起讨论进步。

1
@songhuangcn
加入
更多来自 Song Huang