likes
comments
collection
share

Go Web 编程在 RESTful API 中的应用和最佳实践

作者站长头像
站长
· 阅读数 9

WEB 发展历程 & RESTful API 规范 & JSON 标准响应数据

可以遗憾,但不要后悔。 

我们留在这里,从来不是身不由己。 

——— 而是选择在这里经历生活

目录

本文介绍了 GolangWeb 编程中的应用。从 Web 发展历程开始,分别探讨了九个方面的内容,虽然它们相互独立,但是彼此之间仍有着因果关系。

本文主要聚焦 GolangRESTful API 中的最佳实践,涵盖了相关的开发规范和技术要点。对于前端栈的知识,本文仅作简要介绍。相信阅读完本文的你,将有所收获,能够更深入地理解 RESTful APIWeb 开发中的实现和应用。

值得思考:为什么是 RESTful,怎么用 RESTful 以及怎么用好 RESTful

  1. WEB 发展阶段历程
  2. 前后端分离应用架构
  3. HTTP 基础概念
  4. RESTful 接口规范
  5. API 响应实体格式
  6. 前后端协作规范
  7. 通用的 API 响应实体
  8. 自定义服务端错误码
  9. 统一 JSON 的响应格式

WEB 发展阶段历程

Part 1: 了解 Web 发展历程可以为学习和理解 RESTful API 提供更深入的背景和上下文,从而更好地应用和实践这种基于 HTTP 的架构风格。

前端栈的主要时间线

时期年份描述
原生JavaScript时期早期 - 2006年jQuery 出现之前,使用 JavaScript 进行 DOM 操作非常繁琐,需要编写大量的冗长代码。为了简化 JavaScript 开发,各种库和框架相继出现。它们的出现为 jQuery 的诞生奠定了基础。
jQuery和Bootstrap时期2006年 - 2013年jQuery2006 年首次发布,它大大简化了 DOM 操作和事件处理,成为 Web 开发的标准库之一。Bootstrap2011 年首次发布,它提供了一套易用的 UI 组件和响应式布局,为前端开发提供了很大的便利。
AngularJS时期2010年 - 2016年AngularJS2010 年首次发布,它引入了 MVVM 的概念,提供了一套完整的框架来支持前端开发。AngularJS 在开发企业级应用方面有很大的优势,但也面临着性能和复杂度的问题。
React和Vue时期2013年 - 至今React2013 年首次发布,它提供了一套声明式的 UI 编程模式,可以帮助开发人员更加高效地构建复杂的 UI 界面。Vue2014 年首次发布,它提供了一套易用的 API 和生态系统,成为了一个快速、轻量级的前端框架。ReactVue 都成为了目前最流行的前端框架之一。
Node.js时期2009年 - 至今Node.js 是基于 Chrome V8 引擎开发的 JavaScript 运行环境,它可以在服务器端运行 JavaScript 代码,提供了非阻塞 IO 和事件驱动的编程模型,可以高效地处理大量并发请求。Node.js 也逐渐成为了 Web 开发的主流技术之一。
TypeScript时期2012年 - 至今TypeScript 是由 Microsoft 开发的一种超集 JavaScript 的语言,它提供了更加强大的类型检查和面向对象编程的特性,可以提高代码的可维护性和可扩展性。TypeScript 也逐渐成为了前端开发的主流语言之一。
Web 前端发展时间轴1994-01-011996-01-011998-01-012000-01-012002-01-012004-01-012006-01-012008-01-012010-01-012012-01-012014-01-012016-01-012018-01-012020-01-01HTML1.0超文本标记语言W3C创建网景推出第一款浏览器JavaScript诞生CSS发布XHTML1.0修订Ajax出现CSS3标准jQuery发布V8引擎问世Node发布ES5标准发布NPM工具Angular诞生Webpack工具TypeScript发布React诞生ESLint项目Vue诞生Babel项目HTML5.0规范Electron项目VSCode软件ES6发布(ES2015)Yarn工具WebAssembly标准Vite工具ES2022(ES-Next)里程碑 (Web1.0 - Web2.0 - Web3.0 ...)Web 前端发展时间轴

WEB 1.0 静态 WEB 时代

  1. 静态 Web 时代:早期的 Web 应用,主要是静态网页的展示和信息传递,没有动态交互和数据交互。这个时期的 Web 应用基本上都是由后端来渲染生成 HTML 静态页面,前端的职责相对较少。
  2. 动态 Web 时代:随着后端和网站的发展,开始采用后端技术(如 CGIASPJSP 等)来生成动态内容,实现了网页的动态交互和数据交互。尽管 ASP 等技术可以生成动态的 HTML 页面,但这并没有改变 Web 1.0 时代的核心特点。
前端被戏称为 "切图仔" 的时期主要是在 2000 年代初期到中期,那时候前端的主要工作是将设计师提供的 PSD 文件转化为 HTML 和 CSS 代码,并进行浏览器的兼容性测试和调试。这个时期前端技术相对简单,开发人员的工作主要是对已有技术进行运用和调试。

随着 Web 应用的复杂化和前端技术的不断发展,Angular 和 React 等前端框架的出现和普及,使得前端工程师开始拥有更多的技术和工具来进行 Web 应用的开发和维护,也让前端技术逐渐从单纯的切图转化为一个独立的领域。

Go Web 编程在 RESTful API 中的应用和最佳实践

WEB 2.0 社交网络时代

  1. 前后端不分离时期:
  • 前端主要负责渲染页面和简单交互
  • 后端主要负责业务逻辑和数据处理
  • 依赖于服务器端渲染,使用少量 JSCSS 提升用户体验
  • Ajax 技术出现(Web 2.0 的一个重要特征),但前后端代码仍混合,维护困难
  1. 前后端分离时期:
  • 前后端技术独立开发、部署、维护
  • 前端主要负责页面渲染和用户交互
  • 后端主要负责提供 RESTful API 接口和数据逻辑
  • 前后端通过 HTTP 请求和响应通信
  • 前端框架流行,AngularReactVue 帮助快速构建复杂 Web 应用
Web 2.0 的发展离不开 Web 技术的普及,Ajax 技术的出现,移动设备的普及,前端框架的出现,Node.js 的出现,以及 Web 标准 W3C 组织的推广。这些因素共同促进了 Web 2.0 的发展,为 Web 应用程序的开发提供了更多的技术手段和支持。

Go Web 编程在 RESTful API 中的应用和最佳实践

前后端分离应用架构

Part 2: 前后端分离是 WEB 2.0应用架构的一种趋势,其主要原因是为了更好地实现前后端的职责分离和业务解耦,从而提高 Web 应用的可维护性、可扩展性和可测试性。

前后端分离的意义

前后端分离带来的优势包括:

  1. 拆分关注点:前后端分离可以让前端开发人员专注于 UI 和用户体验,而后端开发人员专注于业务逻辑和数据处理。这种拆分可以使得开发人员更加专注于自己的领域,提高了开发效率和质量。
  2. 松耦合:前后端分离可以降低前端和后端之间的耦合度,使得系统更加灵活和可扩展。在前后端分离的架构中,前端和后端之间通过 API 进行交互,API 的格式是明确的,双方的职责清晰明了,可以避免不必要的沟通和重复的工作。
  3. 前端技术发展:前端技术的不断发展使得前端可以承担更多的工作,比如模板渲染、数据处理、路由管理等等,这些工作原本是后端的职责,但是现在可以通过前端框架和工具来实现。
  4. 提高性能:前后端分离可以提高 Web 应用的性能,因为前端可以缓存数据、预加载资源,减少了对后端的请求次数,同时前端也可以通过一些技术来提高用户体验,比如 PWA(Progressive Web App)

前后端分离面临的主要挑战包括:

  1. 前后端开发人员需要具备不同的技能和知识体系,对于中小型公司来说,可能需要投入更多的人力物力去完成前后端分离的开发。
  2. 前后端分离带来了前后端通信的问题,需要采用特定的技术协议和数据格式,例如 RESTful APIJSON 数据格式。
  3. 前后端分离会导致页面渲染性能的下降,需要采用一些优化措施,例如异步加载和缓存机制。

Go Web 编程在 RESTful API 中的应用和最佳实践

在这样的背景下,随着前后端分离的趋势,以 AngularReactVue 为代表的前端框架得到了广泛应用,为 Web 2.0 飞速发展开启了新的时代。这些前端框架和工具的出现,使得前端开发人员可以承担更多的工作,从而提高了开发效率和质量,同时也带来了新的挑战和问题。

接口定义职责

前后端交互接口:

通俗意义上的这类接口通常是由后端工程师定义的,因为接口是由后端服务器提供的数据和功能。后端工程师需要设计和实现 API(应用程序编程接口),这些 API 定义了客户端可以调用的请求和响应。

前端工程师可以使用这些 API 来开发前端应用程序,通过调用后端提供的接口来获取数据并与后端交互。

在一些情况下,前端工程师也可能会参与 API 设计的过程中,以确保 API 满足前端应用程序的需求。

OpenAPI 接口:

Open API 接口可以由上游或下游定义,具体取决于 API 的设计和实现。

如果 Open API 接口是由上游系统(如数据源或服务提供商)提供的,那么上游系统将定义和实现 API,并将其发布给下游系统(如应用程序或客户端)。在这种情况下,下游系统需要遵循上游系统定义的 API 规范,以便与上游系统进行交互并获取所需的数据或服务。

另一方面,如果 Open API 接口是由下游系统定义的,那么下游系统将定义和实现 API,并将其发布给上游系统。在这种情况下,上游系统需要遵循下游系统定义的 API 规范,以便为下游系统提供所需的数据或服务。

无论是上游还是下游定义 Open API 接口,都需要确保 API 的规范清晰、一致和易于使用,以便各方能够顺利进行交互和集成。

💡 提示: 下文如无特殊说明,“接口” 一词一律代指前后端交互的 RESTful API 接口。

前后端协作开发

  1. 前后端通常会通过异步接口 (AJAX/JSONP) 来进行编程开发
  2. 前后端都各自有自己的开发流程,构建工具和测试集合
  3. 关注点分离,前后端变得相对独立并松耦合
后端前端
编程语言JavaC#PythonPHPNodeJsHTMLCSSJavaScript
WEB框架Spring BootASP.NETDjangoLaravelExpress.jsAngularJSReactVue.js
主要职责数据存储、逻辑处理、接口提供等任务,聚焦于各类后端应用组件、上下游数据打通,数据安全、持久化、性能提升等展示用户界面和处理用户交互,轻量的数据逻辑处理,聚焦于界面交互,渲染逻辑等
服务对象响应大前端(浏览器、APP、小程序、桌面端)响应用户
软件架构模式服务端 MVC 架构 (Model-View-Controller)客户端 MV* 架构,MVVM(Model-View-ViewModel)MVP(Model-View-Presenter)
运行环境后端代码运行在服务器上,通过网络接口向客户端(如浏览器)提供数据和服务前端代码运行在客户端浏览器中,可以向服务器发起请求获取数据或服务,并将其展示给用户

当用户在浏览器中访问 Web 应用程序时,浏览器将向服务器发起请求,服务器通过后端代码处理请求并返回数据或服务,浏览器接收并解析响应数据,并使用前端代码渲染用户界面。整个过程中,后端代码和前端代码分别在服务器端和浏览器端运行,共同构成了一个完整的 Web 应用程序。

Go Web 编程在 RESTful API 中的应用和最佳实践

HTTP 基础概念

Part 3: 在正式介绍 RESTful 之前,快速补充下 HTTP 协议的相关概念,它是一种基于 TCP/IP 通信协议的应用层协议,用于传递超文本到本地浏览器,并包括协议、头信息、状态码和请求方法等内容。

HTTP Protocol

  • HTTP 协议特点
特点描述
无连接每次请求都需要与服务器建立一个连接,请求结束后,连接就会关闭。这种连接的特性使得 HTTP 不能支持客户端和服务器之间的长连接,因此每次请求都会产生较大的开销,这也是 HTTP 的一个缺点。
无状态每个请求都是相互独立的,服务器不会在不同请求之间保存任何状态信息。这意味着服务器无法知道客户端之前的操作,因此不能自动适应客户端的需求。
基于请求和响应HTTP 协议是基于请求和响应的协议。客户端向服务器发送请求,服务器则会向客户端发送响应。这种方式简单明了,使得 HTTP 易于实现和使用。
可扩展性HTTP 的请求和响应消息是由 HTTP 头和正文组成的,头部信息包含了请求和响应的元数据,正文部分包含了实际的数据。这种结构使得 HTTP 协议可以很容易地被扩展,增加新的头部字段和正文格式。
支持多媒体HTTP支持多种类型的数据,如 HTMLCSSJavaScript、图片、视频等。
明文传输HTTP 是明文传输的,即数据在传输过程中不会被加密,因此存在安全隐患。
  • HTTP 工作模式
协议版本工作模式描述
http1.0单工因为是短连接,客户端发起请求后,服务端处理完请求并收到客户端的响应后,即断开连接。
http1.1半双工采用的是请求/响应模型,在这种模式下,客户端和服务器之间只能单向通信,即在同一时间内只能有一方向另一方发送消息。HTTP/1.1 默认开启长连接 keep-alive,一个连接可以发送多个请求和响应,从而减少了建立连接和断开连接的开销,提高了性能。对于 HTTP/1.1,请求头中应该包含 Host 字段。
http2.0全双工允许客户端和服务器在同一时间内进行双向通信。在 HTTP/2 中,通信双方都可以发送消息,而且可以同时发送多个消息,这些消息被分为帧并通过一个共享的连接发送,这种方式比 HTTP/1.x 中的多次请求和响应更高效。此外,HTTP/2 还支持多路复用,即可以通过一个连接并发处理多个请求和响应,从而减少了延迟和资源占用,提高了性能。对于 HTTP/2.0,请求头中则应该包含 :authority 字段。

💡 提示:HTTP/1.1 规范中规定,如果客户端发送的请求报文中没有包含 Host 头字段,那么服务器应该返回一个 400 Bad Request 的响应码。这是因为在 HTTP/1.1 中,引入了虚拟主机的概念,同一个 IP 地址下可以托管多个域名的网站。因此,服务器需要根据 Host 头字段的值来确定客户端请求的是哪个网站的资源,从而提供正确的响应。

HTTP Headers

  • HTTP 请求头

客户端通过请求头会传递给服务器遵循 HTTP 协议的信息,并按照该协议内容进行 URL 解码。

名称说明示例
Accept表示浏览器可接受的 MIME 类型Accept: text/html
Accept-Charset表示浏览器可接受的字符集Accept-Charset: utf-8, iso-8859-1;q=0.5
Accept-Encoding表示浏览器能够进行解码的数据编码方式Accept-Encoding: gzip, compress, br
Accept-Language表示浏览器所希望的语言种类,当服务器能够提供一种以上的语言版本时要用到Accept-Language: en-US,en;q=0.5
Authorization表示授权信息,通常出现在对服务器发送的 WWW-Authenticate 头的应答中Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l
Connection表示浏览器请求完后是断开链接还是维持链接Connection: keep-alive
Content-Length表示请求消息正文的长度Content-Length: 408
Cookie表示 HTTP 请求报头包含存储先前通过与所述服务器发送的 HTTP cookies Set-CookieCookie:PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;
From请求发送者的 email 地址,由一些特殊的 Web 客户程序使用,浏览器不会用到它From: webmaster@example.org
Host初始要访问的服务器 URL 地址,通常是域名,或主机和端口号Host: developer.cdn.mozilla.net
Pragma表示服务器必须返回一个刷新后的文档,即使它是代理服务器而且已经有了页面的本地拷贝Pragma: no-cache
Referer表示客户机是哪个页面来的(防盗链)Referer: https://developer.mozilla.org/en-US/docs/Web/JavaScript
User-Agent表示用户代理请求头包含一个特征串,其允许网络协议对等体,以确定请求软件的用户代理的应用程序类型,操作系统,软件供应商或软件版本Mozilla/5.0(X11; Linux x86_64) AppleWebKit/537.36(KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
  • HTTP 响应头

服务器通过响应头会传递给客户端遵循 HTTP 协议的信息,并按照该协议内容进行动作。

名称说明示例
Allow表示服务器支持哪些请求方法(如GETPOST等)Allow:GET,POST,HEAD
Content-Encoding表示文档的编码 Encode 方法,只有在解码之后才可以得到 Content-Type 头指定的内容类型Content-Encoding: gzip
Content-Length表示内容长度,只有当浏览器使用持久 HTTP 连接时才需要这个数据Content-Length: 549
Content-Type表示后面的文档属于什么 MIME 类型Content-Type: text/html; charset=utf-8
Date表示当前的 GMT 时间Date: Wed,21 Oct 201507:28:00GMT
Expires表示应该在什么时候认为文档已经过期,从而不再缓存它Expires: Wed,21 Oct 201507:28:00GMT
Last-Modified文档的最后改动时间Last-Modified: Wed,21 Oct 201507:28:00GMT
Location表示客户应当到哪提取文档,Location 通常不是直接设置的,而是后端语言的Redirect 方法Location:/index.html
Refresh表示浏览器应该在多少秒之后刷新文档Refresh: 10; url=/index.html
  • HTTP 实体头

实体头用作实体内容的元信息,描述了实体内容的属性,包括实体信息类型,长度,压缩方法,最后一次修改时间,数据有效性等。(可参考响应头)

Allow
Content-Encoding
Content-Language
Content-Length
Content-Location
Content-MD5
Content-Range
Content-Type
Expires
Last-Modified
  • HTTP 扩展头

HTTP 消息中,也可以使用一些在 HTTP1.1 正式规范里没有定义的头字段,这些头字段统称为自定义的 HTTP 头或者扩展头,它们通常被当作是一种实体头处理。

现在流行的浏览器实际上都支持以下常用的扩展头字段

Content-Disposition
Content-Type
Cookie
Set-Cookie
Refresh

Request Method

  • HTTP1.0 定义了三种请求方法:GETPOSTHEAD 方法。
  • HTTP1.1 新增了五种请求方法:OPTIONSPUTDELETETRACECONNECT 方法。
方法描述
GET请求指定的页面信息,并返回实体主体。
HEAD类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头。
POST向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST 请求可能会导致新的资源的建立和/或已有资源的修改。
PUT从客户端向服务器传送的数据取代指定的文档的内容。
DELETE请求服务器删除指定的页面。
CONNECTHTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器。
OPTIONS允许客户端查看服务器的性能。
TRACE回显服务器收到的请求,主要用于测试或诊断。

Status Code

  • 1xx 状态码:信息请求收到,继续处理
  • 2xx 状态码:代表成功,行为被成功地接收、理解及采纳
  • 3xx 状态码:重定向,完成此请求必须进一步处理
  • 4xx 状态码:客户端错误,请求包含语法错误或请求无法实现
  • 5xx 状态码:服务端的内部错误
状态码描述说明
100Continue客户必须继续发出请求
101Switching Protocols客户要求服务器根据请求转换 HTTP 协议版本
200OK[GET] 服务器端成功返回用户请求的数据
201Created[POST/PUT/PATCH] 用户新建或修改数据成功
202Accepted该请求已经进入后台排队(一般是异步任务)
204No Content[DELETE] 用户删除数据成功
300Multiple Choices请求的资源可在多处得到
302Found临时重定向,服务器向客户端返回该状态码时,通常还会在响应头中添加一个 Location 字段,告诉客户端需要重定向到哪个 URL 上去。浏览器会自动跳转到这个新的 URL
303See Other建议客户访问其他 URL 页面
304Not Modified不需要重新传输请求的资源,这是对缓存资源的隐式重定向
307Temporary Redirect该资源已暂时移动到由 Location 给定的 URL
308Permanent Redirect该资源已明确永久移动到 Location 标题给定的 URL
400Bad Request用户发出的请求有错误,服务器不理解客户端的请求,未做任何处理
401Unauthorized表示用户没有权限(令牌、用户名、密码错误)
403Forbidden表示用户得到授权了,但是访问被禁止了,不具有访问资源的权限
404Not Found所请求的资源不存在,或不可用
405Method Not Allowed用户已经通过了身份验证,但所用的 HTTP 方法不在它的权限之内
406Not Acceptable用户的请求的格式不可得(如用户请求的是 JSON 格式,但是只有 XML 格式)
410Gone用户请求的资源被转移或被删除,且不会再得到的
415Unsupported Media Type客户端要求的返回格式不支持,如 API 只能返回 JSON 格式,但是客户端要求返回 XML 格式
422Unprocessable Entity客户端上传的附件无法处理,导致请求失败
429Too Many Requests客户端的请求次数超过限额
500Internal Server Error服务器遇到阻止它履行请求的意外情况
502Bad Gateway服务器充当网关或代理时收到来自上游服务器的无效响应
503Service Unavailable服务器当前尚未准备好处理请求
504Gateway Timeout服务器充当网关或代理时无法及时得到响应
505HTTP Version Not Supported服务器不支持请求中使用的 HTTP 版本
511Network Authentication Required客户端需要进行身份验证才能获得网络访问权限

💡 提示: 事实上,这些 HTTP 状态码并不需要刻意去记住。在实际项目中,通常只会使用最基础的一些原生状态码,例如 200302404500 等。其他的状态码可能会通过自定义错误码来呈现,以更好地表达业务意义。举例来说,如果你正在开发一个教育系统,那么就会有很多业务错误与教育相关;如果是电商系统,则业务错误可能与支付、订单相关等。即便是原生状态码能够准确地描述响应信息,也不建议直接使用。例如,对于缺少 token 的情况,一般会采用返回原生状态码 200 正常状态,在自定义业务错误码中判断,而不是直接使用 401 状态码。这样能够更好地让前端代码编写并显示,并避免不必要的影响。

RESTful 接口规范

Part 4: 当我们了解了 HTTP 基本概念、Web 发展历史以及前后端分离架构之后,我们会很快意识到前后端分离所带来的最大问题是数据传输。为了解决这个问题,我们需要了解前端如何获取数据以及后端如何返回数据。这涉及到一些概念,如 URIHTTP 动词、状态码和资源等。在接下来的内容中,我们将对这些概念进行介绍。

RESTful 概念介绍

URIUniform Resource Identifier 的缩写,它是标识资源的唯一方式。在 RESTful API 中,我们使用 URI 来定位资源,注意 URI 应该是名词,而不是动词。

请求方式      版本       资源名称       资源ID        子资源名称        子资源ID
[GET]    /{version}/{resources}/{resource_id}/{sub_resources}/{sub_resource_id}

HTTP 动词指定对资源进行的操作。RESTful API 中常见的 HTTP 动词包括 GETPOSTPUTDELETE

Resource 是由 URI 标识的实体。在 RESTful API 中,我们使用资源来表示应用程序中的任何实体,例如用户、文章、评论等。

总之,RESTful API 是一种简单、清晰、易于理解的方式,使得不同的系统之间可以进行数据的交互。

RESTful 成熟度模型

  • Level 0:使用请求响应模式的基础架构。
  • Level 1:引入资源的概念,让每个资源可以单独创建一个 URI,并使用 Resources 分而治之的方法来处理复杂业务逻辑。
  • Level 2:严格遵守 HTTP 协议定义的动词,使用 HTTP 响应状态码来规范化 Web API 的标准。
  • Level 3:使用超媒体 (HATEOAS),使协议具备自我描述的能力。

通常成熟度模型能够达到 Level 2 就已经足够标准了!

Go Web 编程在 RESTful API 中的应用和最佳实践

标准的 API 示例

下面是一个简易博客网站的 RESTful API,我们可以使用以下 URI 来标识资源:

GET     /api/blogs                                 - 获取所有文章
GET     /api/blogs/:id                             - 获取指定 ID 的文章
POST    /api/blogs                                 - 创建一篇新文章
PUT     /api/blogs/:id                             - 更新指定 ID 的文章
DELETE  /api/blogs/:id                             - 删除指定 ID 的文章
POST    /api/blogs/:id/actions/like                - 文章顶一下
POST    /api/blogs/:id/actions/dislike             - 文章踩一下
GET     /api/blogs/:id/comments                    - 返回指定文章的评论列表
GET     /api/blogs/:id/comments/:id                - 获取指定文章的单个评论
POST    /api/blogs/:id/comments/:id                - 为指定文章创建一条评论
DELETE  /api/blogs/:id/comments/:id                - 删除某条评论及子评论
POST    /api/blogs/:id/comments/:id/actions/reply  - 对某条评论进行回复
POST    /api/blogs/:id/comments/:id/actions/top    - 神评置顶

💡 提示: 我们应该在遵循 RESTful API 的规范时,尽可能地使用标准的资源路径和 HTTP 方法来实现接口功能是比较好的实践。但是,有些情况下可能确实难以使用标准的 RESTful API 命名方式,例如一些复杂的业务逻辑。这时候,可以考虑使用一些特殊的 actions 来实现接口功能,但需要注意尽量避免滥用 actions,保证接口的可维护性和可扩展性。

RESTful 请求格式规范

以下都是符合 RESTful API 规范的使用细节:

关注点示例说明
请求参数中多单词使用蛇形/api/blogs?page_size=20&page_num=5可以让 URI 更加可读且易于理解
请求路径中多单词使用中横线/api/blogs/my-personal-center当使用中横线来分隔单词时,能够帮助搜索引擎更好地理解你的网站结构,使其对 SEO 更加友好。因为搜索引擎可以将中横线解释为单词的分隔符,这样可以让搜索引擎更好地理解你的网站结构和内容,提高网站在搜索引擎结果页中的排名。
请求体 或 响应体{ "username": "admin", "password": "12345", "remember_me": 1 }所有 JSON 数据的 key 一律使用蛇形命名,使得数据更加易于理解,也方便进行数据处理。
请求头 key{ "Token": "xxxxxx", "X-Cookie": "xxxxxxxxx" }使用首字母大写,多单词每个首写字母大写并用中横线连接,这样可以使得请求头更加规范化,易于理解和维护。

💡 提示: 除了请求头和请求路径建议使用中横线,最为常用的请求参数、请求体(响应体)都应该采用蛇形命名法(snake_case)来表示,例如 user_idfirst_name 等。一个友好的 API 传输的数据不建议使用静态语言中的驼峰命名习惯。

API 响应实体格式

Part 5: 上面我们介绍了 HTTP 协议及 RESTful API 的设计风格。接下来,我们将重点讲解在 API 开发中最常用的 application/json 实体类型的请求和响应。在本章节中,我们暂不涉及其它 MIME 媒体文件类型,和其它接口通讯技术,如 WebSocketsSSEGraphQLgRPC 等。

HTTP 状态码 [200]

HTTP 协议规定的状态码 (100 - 600) 无法完全描述业务错误信息。因此,为了满足业务需求,我们在 HTTP 响应中定义了自定义返回码。这些自定义返回码的编号一般大于 100010000。我们建议不要将这些自定义返回码与 HTTP 状态码混淆。

在实际应用中,我们需要根据具体业务场景来选择合适的 HTTP 状态码和自定义返回码,以便能够准确地反映出请求的处理状态。请注意,对于业务错误而言,直接返回 400500 是不合适的,我们应该选择更加精准的自定义返回码来描述业务错误信息。

下面记录了业务成功和失败的情况以及响应异常的结构。考虑到不同业务的差异性,只列出了最常见的几个字段,根据实际情况可以进行调整,例如添加 occurrence_time 记录出错时间,方便快速定位问题等。

业务正常

  • 响应实体为空️
HTTP/1.1 200 OK
Content-Type: application/json

{
    "code": 0,
    "msg": "success",
    "data": null
}
  • 响应实体格式
HTTP/1.1 200 OK
Content-Type: application/json

{
    "code": 0,
    "msg": "success",
    "data": {
        "name": "Pokeya",
        "age": 30,
        "male": true,
        "job": "developer",
        "tech_stack": "backend"
    }
}
  • 响应列表格式
HTTP/1.1 200 OK
Content-Type: application/json

{
    "code": 0,
    "msg": "success",
    "data": {
        list: [
            {
                "id": 1,
                "name": "Pokeya",
                "age": 30,
                "male": true,
                "job": "developer",
                "tech_stack": "backend"
            },
            {
                "id": 2,
                "name": "King",
                "age": 31,
                "male": true,
                "job": "dba",
                "tech_stack": "database"
            },
            ...
        ]
    }
}
  • 响应分页格式
HTTP/1.1 200 OK
Content-Type: application/json

{
    "code": 0,
    "msg": "success",
    "data": {
        page_info: {
            "page_num": 5,      // 第5页
            "page_size": 20,    // 每页20条
            "total_count": 281, // 共281条
            "total_pages": 15   // 共15页
        },
        items: [
            {
                "id": 1,
                "name": "Pokeya",
                "age": 30,
                "male": true,
                "job": "developer",
                "tech_stack": "backend"
            },
            {
                "id": 2,
                "name": "King",
                "age": 31,
                "male": true,
                "job": "dba",
                "tech_stack": "database"
            },
            ...
        ]
    }
}
  • 特殊内容规范
    • 前端的静态初始值,如下拉框、复选框、单选框(统一收口到后端,前端只做渲染,尽量避免业务逻辑处理)。
    • Boolean 类型在 JSON 数据传输的定义形式,使用 1/0 代替 true/false
    • Date 类型在 JSON 数据传输的定义形式,一律使用字符串类型,具体日期格式因业务而定。

业务失败

  • 标准描述
HTTP/1.1 200 OK
Content-Type: application/json

{
    "code": 10004,
    "msg": "invalid request parameter"
}
  • 详细描述
HTTP/1.1 200 OK
Content-Type: application/json

{
    "code": 10004,                           // 某类错误的错误码
    "msg": "invalid request parameter",      // 错误的摘要信息,便于快速定位
    "detail": "ip_address field not found"   // 记录错误的详细错误信息及原因
}

HTTP 状态码 [302]

  • 后端的 endpoint 视图函数给前端 302 路由重定向的指令
HTTP/1.1 302 Found
Location: "https://www.example.com/new-location"

HTTP 状态码 [404]

  • 无效的路由请求
HTTP/1.1 404 Not Found
Content-Type: application/json

{
    "code": 10001,
    "msg": "invalid URL path"
}

HTTP 状态码 [500]

  • 服务端发生严重的 panic/fatal 级别的错误
HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{
    "code": 50002,
    "msg": "failed",
    "detail": "unknown internal error"
}

前后端协作规范

Part 6: 建议在 API 设计中选择一种命名方式并保持一致性,同时在前端中也要统一采用相同的命名方式进行解析,可以借助中间件或工具来转换命名方式,以保证数据传递的正确性。无论使用哪种方式,都需要在前后端之间建立明确的命名规范。

响应命名规范冲突

  • Pythondict / Gostruct / APIJSON / JavaScriptObject
阶段语言/规范代表key 键命名建议说明
后端(动态语言)Pythondict 蛇形python dictkeyAPI 命名规范一致,完美适配,无需任何调整
后端(静态语言)Gostruct 大驼峰如需导出 JSON 数据,将 struct 的字段名使用大驼峰(公开),另外设置 json tag 为蛇形,完美适配解析
HTTP/1.1 传输RESTful APIjson 蛇形RESTful 规范并没有强制规定 JSON 数据中的 key 命名方式,但是建议使用小写字母加下划线的蛇形命名方式,这样可以增加可读性和易于理解,也有利于与其他开发者协作开发。
前端 Web 浏览器JavaScriptObject 小驼峰通常在 JavaScript 中,对象的键名建议使用小驼峰命名法,也就是第一个单词的首字母小写,后面的单词首字母大写并去掉空格和符号。这是 JavaScript 社区的一个约定俗成的规范,可以提高代码的可读性和可维护性。

约定响应命名规范

我们不难看出,后端到 HTTP 可以完美适配 RESTful API 的规范,但如果这样的话,前端需要解析蛇形的属性,我们知道无论 JS 还是 TS 社区都推崇对象属性使用小驼峰命名法,那如何解析呢?两种方案:

  1. 由于 RESTful API 仅仅作为建议,为了让前端更好的解析,我们后端可以适当牺牲些代码规范,将所有 JSON key 换为 小驼峰,使用 HTTP 发送的 RESTful API JSON 数据,这样前端 JS/TS 就可以完美的解析了。这是前后端协作,最方便快捷地解决办法。

  2. 如果后端不愿意妥协,后端就是用标准的蛇形来返回 API 数据。那么前端还剩下两个办法。

    2-1. 舍弃前端的小驼峰命名规范,通过在属性名上使用反引号 (backtick) 来指定属性名,从而解决属性命名规则不一致的问题,只不过用反引号方式对前端的 Object 操作确实不太友好。另外,如果你的前端工程启用了 ESLint 并且配置了 camelcase 规则,它会警告你使用了蛇形命名的键。你可以通过在 ESLint 配置中禁用 camelcase 规则来解决这个问题,或者在相关代码上方添加注释来忽略特定的 ESLint 规则。这样 ESLint 将会忽略这行代码的 camelcase 规则检查。总之,这种方式了解即可,不推荐!!!

    // eslint-disable-next-line camelcase
    console.log(data[`page_info`]);
    

    2-2. 当后端不肯妥协在 API 中传递小驼峰的 Key 属性,前端也不妥协在接收解析 API 值的时候使用蛇形,那么,只能借助中间件来完成命名的转换,以保证数据传递的正确性。在前端 axios.interceptors.response 拦截响应并进行 “蛇形 -> 小驼峰” 的转换。这样就完美解决了,属性可以直接 “对象.属性” 操作出来了。

    console.log(data.pageInfo);
    

    但是需要告知的是,由于使用递归去处理多层级 JSON,“格式转换函数” 会增加前端浏览器额外开销负担。并且写在 axios.interceptors 中,意味着无论 API 接口是否适配 JS 的小驼峰,都会验证这个转换函数,这无疑是对资源的一种浪费,性能肯定也是会受损的。我们要权衡使用该方式的利弊。

约定请求命名规范

上面聊完了有关响应的命名差异性,请求其实也是一样的,如果前后端都不肯妥协,前端代码中执意传入驼峰的 Key,同样建议在前端 axios.interceptors.request 请求拦截器中添加格式转换 “小驼峰 -> 蛇形”,对请求参数和请求体进行格式转换。这样也完美的解决既不会影响前端代码的规范,又不影响 RESTful API 和 后端的规范。

当然如果前端不做任何处理,那么 RESTful API 中的 JSON key 就只能是驼峰的了,此时,皮球就扔给了后端,同样后端也可以写中间件来解决,其实没有必要,如果使用 Go 语言,直接改变 json tag 为驼峰就可以解析数据了,写法还是很灵活的。

一个完整的示例

后端准备数据,响应给前端:

type PageInfo struct {
    PageNum    int `json:"page_num"`
    PageSize   int `json:"page_size"`
    TotalCount int `json:"total_count"`
    TotalPages int `json:"total_pages"`
}

func main() {
    r := gin.Default()
    r.GET("/list", func(c *gin.Context) {
        p := PageInfo{
            PageNum: 5,
            PageSize: 20,
            TotalCount: 281,
            TotalPages:15,
        }
        c.JSON(200, &p)
    })
    _ = r.Run()
}

比如,后端传递标准的 RESTful API 格式数据,响应体如下:

HTTP/1.1 200 OK
Content-Type: application/json

{
    "page_info": {
        "page_num": 5,
        "page_size": 20,
        "total_count": 281,
        "total_pages": 15
    }
}

前端进行解析处理,在拦截器中添加转换函数:

import Case from 'case';

// 蛇形转驼峰函数,递归处理json数据
export const convertSnakeToCamel = (data: Record<string, any>): any => {
  if (typeof data !== 'object' || data === null) {
    return data;
  }
  if (Array.isArray(data)) {
    return data.map((item) => convertSnakeToCamel(item));
  }
  const camelCaseData: Record<string, any> = {};
  Object.keys(data).forEach((key) => {
    const camelCaseKey = Case.camel(key);
    camelCaseData[camelCaseKey] = convertSnakeToCamel(data[key]);
  });
  return camelCaseData;
};

为了演示方便,前端业务代码也都直接写在拦截器中了:

import axios from 'axios';
import { convertSnakeToCamel } from '@/utils/case';

export interface Pagination {
  pageNum: number;
  pageSize: number;
  totalCount: number;
  totalPages: number;
}

axios.interceptors.response.use(
  (response: AxiosResponse<Pagination>) => {
    // 转换前(副作用是可能引发eslint报错,代码不美观)
    console.log(response[`page_num`]);
    console.log(response[`page_size`]);
    console.log(response[`total_count`]);
    console.log(response[`total_pages`]);
    // 格式转换
    const res = convertSnakeToCamel(response);
    // 转换后(驼峰完美适配前端命名规范)
    console.log(res.pageNum);
    console.log(res.pageSize);
    console.log(res.totalCount);
    console.log(res.totalPages);
    return res;
  },
  (error) => {
    return Promise.reject(error);
  }
);

通用的 API 响应实体

Part 7: 当我们在编写应用程序时,通常需要定义一些通用的数据结构和类型,这些通用的数据结构和类型可能被多个模块和文件使用。为了提高可读性和可维护性,我们通常会将这些通用的数据结构和类型单独定义在一个包或目录中,以便于管理和复用。

后端 - Go 定义泛型结构体

  • 对于后端而言,我们可以将通用的响应实体定义在一个 entity 包中,这样不仅可以减少代码冗余,还可以使代码更加清晰和易于维护
package entity

type Response[T any] struct {
   Code int    `json:"code"`
   Msg  string `json:"msg"`
   Data T      `json:"data"`
}

type PageInfo struct {
   PageNum    int `json:"page_num"`
   PageSize   int `json:"page_size"`
   TotalCount int `json:"total_count"`
   TotalPages int `json:"total_pages"`
}

type PaginationEntity[T any] struct {
   PageInfo PageInfo `json:"page_info"`
   Items    []T      `json:"items"`
}

type ListEntity[T any] struct {
   List []T `json:"list"`
}
  • 编写一个简单的接口,供前端请求调用
package main

import (
    "net/http"

    . "your-project/common/entity"

    "github.com/gin-gonic/gin"
)

type Users struct {
    ID        int    `json:"id"`
    Name      string `json:"name"`
    Age       int    `json:"age"`
    Male      bool   `json:"male"`
    Job       string `json:"job"`
    TechStack string `json:"tech_stack"`
}

func main() {
    router := gin.Default()
    router.GET("/api/users", func(c *gin.Context) {
        resp := Response[PaginationEntity[Users]]{
            Code: 0,
            Msg:  "success",
            Data: PaginationEntity[Users]{
                PageInfo: PageInfo{
                    PageNum:    5,
                    PageSize:   20,
                    TotalCount: 281,
                    TotalPages: 15,
                },
                Items: []Users{
                    {
                        ID:        1,
                        Name:      "Pokeya",
                        Age:       30,
                        Male:      true,
                        Job:       "developer",
                        TechStack: "backend",
                    },
                    {
                        ID:        2,
                        Name:      "King",
                        Age:       31,
                        Male:      true,
                        Job:       "dba",
                        TechStack: "database",
                    },
                },
            },
        }
        c.JSON(http.StatusOK, resp)
    })
    router.Run(":8080")
}

HTTP - JSON 数据

  • API 内容输出
HTTP/1.1 200 OK
Content-Type: application/json

{
  "code": 0,
  "msg": "success",
  "data": {
    "page_info": {
      "page_num": 5,
      "page_size": 20,
      "total_count": 281,
      "total_pages": 15
    },
    "items": [
      {
        "id": 1,
        "name": "Pokeya",
        "age": 30,
        "male": true,
        "job": "developer",
        "tech_stack": "backend"
      },
      {
        "id": 2,
        "name": "King",
        "age": 31,
        "male": true,
        "job": "dba",
        "tech_stack": "database"
      }
    ]
  }
}

前端 - TS 定义泛型接口

  • 对于前端而言,我们可以将通用的响应 interface 类型定义在一个 types 目录中,这样可以使代码更加可读性高,易于维护和扩展。
export interface PageInfo {
  pageNum: number;
  pageSize: number;
  totalCount: number;
  totalPages: number;
}

export interface PaginationEntity<T> {
  pageInfo: PageInfo;
  items: T[];
}

export interface Response<T> {
  msg: string;
  code: number;
  data: T;
}
  • 前端请求后端的接口,并解析响应内容
import axios from 'axios';
import type { AxiosResponse } from 'axios';
import type { PaginationEntity, Response } from '@/types/http';

export interface User {
  id: number;
  name: string;
  age: number;
  male: boolean;
  job: string;
  techStack: string;
}

export const getUsers = async (): Promise<Response<PaginationEntity<User>>> => {
  const res = await axios.get<Response<PaginationEntity<User>>>('/api/users');
  // 注意这里定义的都是ts规范的小驼峰(解析时应注意接口键名格式问题)
  return res.data;
};

自定义服务端错误码

Part 8: 在实际开发中,我们需要根据具体情况来选择使用原生的 HTTP 状态码或自定义错误码来描述响应信息。使用 HTTP 规范的状态码能够更好地遵循 RESTful API 的规范,但是在某些情况下,通过自定义错误码能够更好地让前端代码处理和显示错误信息。同时,对于客户端来说,过多的错误细节和自定义错误码只会让 API 处理更加复杂,难以理解。因此,我们需要根据实际情况来决定如何取舍,但总体来说,我们应该遵循降低 API 处理复杂度的原则。

如何编写标准的错误码文档

  • 标准的通用错误码应该包括以下几个要素:
  1. 错误码:一个唯一标识该错误的数字或字符串代码。
  2. 说明摘要:简要说明错误的含义,方便开发人员快速定位问题。
  3. 排查建议:对于该错误的可能原因和常见解决方案的描述和建议,有助于开发人员快速解决问题。
  • 通常,错误码应该按模块划分,不同模块的错误码前缀应该不同,例如数据库模块的错误码可以以 "DB" 开头,网络模块的错误码可以以 "NET" 开头等。同时,应该规定错误码的取值范围和分配机制,避免重复和冲突。

  • 可参考: 飞书开放平台抖音开放平台微信开放平台

提供 errox 包设计思路

在自定义错误处理时,有两种常见的思路。

第一种思路是无论错误的具体情况如何,都使用 HTTP 状态码 200,而将更具体的错误信息使用自定义错误码来表示。这种方法的好处在于不会因为错误的具体情况而引发 HTTP 状态码的变化,但是需要定义更多的自定义错误码以表示不同的错误情况。然而,这种方法可能会导致公司内不同系统之间的统一性问题,因为不同平台可能会有不同的设计码。

另一种思路是为每种错误情况都定义一个错误码,并将其映射到一个相应的 HTTP 状态码上。这种方法的理念更接近于使用原生 HTTP 状态码,但需要额外的工作来定义和维护自定义错误码。

其中选择哪种方法需要根据具体情况来考虑。总体来说,我们应该遵循降低 API 处理复杂度为原则进行设计。

设计一个极简的 errorx 包

errorx 是一个轻量级的错误处理库,它提供了一种简单的方式来定义错误码常量,并能够根据请求的语言环境返回相应的错误信息。这个包易于引入和使用,可以简化错误处理的流程,提高代码的可维护性和可读性。然而,对于企业和大型项目来说,这样的定义可能过于基础和简单,需要更加复杂和全面的解决方案来满足其需求。

目录树

common
└── errorx           // errorx 错误包
    ├── code.go      // 定义错误码常量
    ├── locale.go    // 用于判断请求的语言环境,支持en-US/zh-CN
    └── message.go   // HashMap相应的中/英文错误的摘要描述信息

code.go 文件

package errorx

const (
    CodeSuccess = 0
    CodeError   = 50000 // 500
)

/*
提示码(非错误)
*/
const (
    CodeConsultFailedEncoding = 1001
    CodeConsultFailedLanguage = 1002
)

const (
    CodePermanentRedirect = 3001 // 301
    CodeTemporaryRedirect = 3002 // 302
)

/*
错误码
*/
const (
    CodeRequestInvalidUrlPath = 10001 // 404
    CodeRequestInvalidMethod  = 10002
    CodeRequestInvalidParam   = 10003
    CodeRequestInvalidQuery   = 10004
    CodeRequestInvalidBody    = 10005
    CodeRequestInvalidForm    = 10006
    CodeRequestInvalidHeader  = 10007
)

const (
    CodeAuthTokenIllegal = 20001
    CodeAuthTokenExpired = 20002
)

const (
    CodeModelEntryDuplication = 30001
    CodeModelEntryNotExist    = 30002
)

const (
    CodeServiceFatal = 40001
)

const (
    CodeBusinessPanicError   = 50001
    CodeUnknownInternalError = 50002
)

locale.go 文件

package errorx

import (
    "strings"

    "github.com/gin-gonic/gin"
)

type lang uint8

const (
    en lang = iota + 1
    zh
)

func (l lang) String() string {
    switch l {
    case en:
        return "en"
    case zh:
        return "zh"
    default:
        return "en"
    }
}

// getPreferredLanguage returns the preferred language of the client, 
// based on the "Accept-Language" header.
func getPreferredLanguage(c *gin.Context) lang {
    locale := c.GetHeader("Accept-Language")
    if strings.Contains(locale, string(en)) || locale == "" || locale == "*" {
        return en
    }
    if strings.Contains(locale, string(zh)) {
        return zh
    }
    return en
}

message.go 文件

package errorx

import (
    "github.com/gin-gonic/gin"
)

var (
    enUSText = map[int]string{
        CodeSuccess: "success",
        CodeError:   "failed",

        CodeConsultFailedEncoding: "invalid encoding",
        CodeConsultFailedLanguage: "invalid language",

        CodePermanentRedirect: "permanent redirect",
        CodeTemporaryRedirect: "temporary redirect",

        CodeRequestInvalidUrlPath: "invalid URL path",
        CodeRequestInvalidMethod:  "invalid HTTP method",
        CodeRequestInvalidParam:   "invalid dynamic route parameter",
        CodeRequestInvalidQuery:   "invalid request parameter",
        CodeRequestInvalidBody:    "invalid request body",
        CodeRequestInvalidForm:    "invalid form data",
        CodeRequestInvalidHeader:  "invalid request header",

        CodeAuthTokenIllegal: "invalid authentication token",
        CodeAuthTokenExpired: "expired authentication token",

        CodeModelEntryDuplication: "model entry already exists",
        CodeModelEntryNotExist:    "model entry does not exist",

        CodeServiceFatal: "fatal error in service",

        CodeBusinessPanicError:   "panic error in business logic",
        CodeUnknownInternalError: "unknown internal error",
    }

    zhCNText = map[int]string{
        CodeSuccess: "请求成功",
        CodeError:   "请求失败",

        CodeConsultFailedEncoding: "无效的编码",
        CodeConsultFailedLanguage: "无效的语言",

        CodePermanentRedirect: "永久重定向",
        CodeTemporaryRedirect: "临时重定向",

        CodeRequestInvalidUrlPath: "无效的 URL 路径",
        CodeRequestInvalidMethod:  "无效的 HTTP 方法",
        CodeRequestInvalidParam:   "无效的动态路由参数",
        CodeRequestInvalidQuery:   "无效的请求参数",
        CodeRequestInvalidBody:    "无效的请求体",
        CodeRequestInvalidForm:    "无效的表单数据",
        CodeRequestInvalidHeader:  "无效的请求头",

        CodeAuthTokenIllegal: "无效的身份验证令牌",
        CodeAuthTokenExpired: "身份验证令牌已过期",

        CodeModelEntryDuplication: "模型条目已存在",
        CodeModelEntryNotExist:    "模型条目不存在",

        CodeServiceFatal: "服务发生致命错误",

        CodeBusinessPanicError:   "业务逻辑发生崩溃错误",
        CodeUnknownInternalError: "发生未知的内部错误",
    }
)

// 仅返回英文 API 消息
func GetMsg(code int) string {
    return enUSText[code]
}

// 根据当前接口请求的语言设定,返回相应语系
func ApiMsg(c *gin.Context, code int) string {
    locale := getPreferredLanguage(c)
    switch locale {
    case en:
        if msg, ok := enUSText[code]; ok {
            return msg
        }
        fallthrough
    case zh:
        if msg, ok := zhCNText[code]; ok {
            return msg
        }
        fallthrough
    default:
        return GetMsg(CodeConsultFailedLanguage)
    }
}

统一 JSON 的响应格式

Part 9: 让我们以 Gin WEB Framework 为例,一步步地展示如何从最简单的代码进阶到更加优雅、可读性更高、更易于维护的写法。实际上,写法都较为基础,并没有用到非常高阶的用法。

坚韧黑铁

代码:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.GET("/SayHello", func(c *gin.Context) {
        c.JSON(200, map[string]interface{}{
            "result": "Hello World", 
        })
    })
    _ = router.Run(":8080")
}

点评:

  • 无标准 JSON 响应格式。可参考Part 1
  • 另外,URI 路径不建议使用驼峰形式。
  • 这一段位的选手通常为 Go 初学者,前期学习阶段可以使用,但生产通常不建议使用万能的 map[string]interface{} 进行数据返回,接口难以维护,否则会被暴揍。

代码规范原则:

  • 代码中拒绝一切神仙数,拒绝一切直接硬编码的字符串!
  • 基本类型使用 const,复杂类型使用 var 预定义。
  • 尽量使用一些已定义的 shortcut

改进:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

const (
    CodeKey = "code"
    MsgKey  = "msg"
    DataKey = "data"
)

const (
    OkCode = 0
    OkMsg  = "success"

    ErrCode = 50000
    ErrMsg  = "failed"
)

func NewResponseWithH(code int, msg string, data any) (r gin.H) {
    r = gin.H{
        CodeKey: code,
        MsgKey:  msg,
        DataKey: data,
    }
    return
}

func HandlerView(c *gin.Context) {
    data := map[string]any{
        "result": "Hello World",
    }
    resp := NewResponseWithH(OkCode, OkMsg, data)
    c.JSON(http.StatusOK, resp)
}

func main() {
    router := gin.Default()
    router.GET("/hello", HandlerView)
    _ = router.Run(":8080")
}

英勇青铜

代码:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

type Response struct {
    Code int         `json:"code"`
    Msg  string      `json:"msg"`
    Data interface{} `json:"data,omitempty"`
}

type User struct {
    Name      string `json:"name"`
    Age       int    `json:"age"`
    Male      bool   `json:"male"`
    Job       string `json:"developer"`
    TechStack string `json:"tech_stack"`
}

func main() {
    resp := &Response{
        Code: 0,
        Msg:  "success",
        Data: User{
            Name:      "Pokeya",
            Age:       30,
            Male:      true,
            Job:       "developer",
            TechStack: "backend",
        },
    }
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        c.JSON(http.StatusOK, resp)
    })
    _ = router.Run(":8080")
}

点评:

  • JSON 响应格式已初步成型,使用了 struct json tag 进行标准 API 数据响应。
  • 这一段位的选手鱼龙混杂,有刚加入 Go 的小白,有躺平的 CRUD 程序员,也有爱摆烂的大佬。
  • 玩具或者小项目可以这样写,没什么毛病,正式生产项目可不要这样写,依然会被打。

不屈白银

代码:

response.go 文件

package response

type baseResponse struct {
    Code   int    `json:"code"`
    Msg    string `json:"msg"`
    Data   any    `json:"data,omitempty"`
    Detail string `json:"detail,omitempty"`
}

type Response struct {
    Status int
    Body   baseResponse
}

func NewResponse() *Response {
    return &Response{}
}

func (r *Response) SetStatus(status int) *Response {
    r.Status = status
    return r
}

func (r *Response) SetCode(code int) *Response {
    r.Body.Code = code
    return r
}

func (r *Response) SetMsg(msg string) *Response {
    r.Body.Msg = msg
    return r
}

func (r *Response) SetData(data any) *Response {
    r.Body.Data = data
    return r
}

func (r *Response) SetDetail(detail string) *Response {
    r.Body.Detail = detail
    return r
}

func (r *Response) GetStatus() int {
    return r.Status
}

func (r *Response) GetBody() *baseResponse {
    return &r.Body
}

main.go 文件

package main

import (
    "net/http"

    . "your-project/common/errorx"
    "your-project/common/response"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        resp := response.NewResponse()
        resp.
            SetStatus(http.StatusOK).
            SetCode(CodeSuccess).
            SetMsg(GetMsg(CodeSuccess)).
            SetData(map[string]any{
                "Greeting": "hello",
            })
        c.JSON(resp.Status, resp.Body)
    })
    _ = router.Run(":8080")
}

点评:

  • 使用了 Builder 建造者模式。
  • 隐蔽原 struct 的属性,通过方法设置改变属性值。
  • 使用了方法链的形式,方便操作者调用。
  • 加入了 errorx 包,来定义项目规范的错误码。
  • 使用构造函数来创建一个指向 Response 结构的指针。
  • 尽管写法上有了些许的进步,反倒代码量变更多了,甚至不如上面直接赋值原始 struct,如何选择视情况而定,该方式并非是一种很好的解法。

概念:

术语说明
方法设置Go 语言中,我们可以通过绑定方法来修改结构体的属性,这种方式通常被称为 “方法设置” 或 “函数设置”。与直接修改结构体属性不同,这种方式更加安全,因为可以在绑定方法中对输入进行验证,从而避免了错误的修改。同时,它还可以更好地封装结构体属性,使得外部无法直接修改结构体属性,从而提高了代码的可维护性和可读性。
方法链方法链 (Method Chaining) 是一种在面向对象编程中常见的技术,它通过将方法的返回值设置为对象本身来实现连续调用多个方法的效果。也就是说,在一个对象上连续调用多个方法,每个方法都返回对象本身,以便在调用链中继续使用下一个方法,从而实现一系列操作的连贯性。方法链可以使代码更加简洁、易读和易于维护。

荣耀黄金

使用 Go 1.18 特性 T 泛型

response.go 文件

package response

// 这里直接写 Response[T any] 即可,万能空接口,包含一切
type Response [T any | *any | []any | map[string]any | []map[string]any] struct {
   Code   int    `json:"code"`
   Msg    string `json:"msg"`
   Data   T      `json:"data,omitempty"`
   Detail string `json:"detail,omitempty"`
}

func ParseResponse[T any]() *Response[T] {
   return &Response[T]{}
}

func BaseResponse[T any](code int, msg string, data T) *Response[T] {
   return &Response[T]{
      Code: code,
      Msg:  msg,
      Data: data,
   }
}

main.go 文件

package main

import (
    "net/http"

    . "your-project/common/errorx"
    . "your-project/common/response"
    . "your-project/model"

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        u := User{
           Name:      "Pokeya",
           Age:       30,
           Male:      true,
           Job:       "developer",
           TechStack: "backend",
        }
        resp := BaseResponse[*User](CodeSuccess, GetMsg(CodeSuccess), &u)
        c.JSON(http.StatusOK, resp)
    })
    _ = router.Run(":8080")
}

点评:

  • 其实和之前的普通结构体没什么区别,只不过使用泛型特性,拉了点好感分。
  • Response 泛型结构体可以用于作为 gin 框架的响应返回,也可以用于解析其他接口的响应数据。
  • 当然 anyinterface{} 空接口类型也可以当作 “泛型”,这不过 T 泛型可以让代码更加简洁,合理使用。

优势:

相较于上面的普通结构体,泛型结构体具有以下优势:

  1. 更严格的类型约束:Data 字段的类型是泛型的,但是可以通过类型约束指定泛型类型的范围,例如 any 表示可以是任意类型,*any 表示可以是任意指针类型,[]any 表示可以是任意切片类型,map[string]any 表示可以是任意键为字符串类型的字典类型,[]map[string]any 表示可以是任意字典类型的切片类型。这样可以更加精确地指定 Data 字段的类型,减少类型错误的发生。
  2. 更好的类型推断:由于 Data 字段的类型是泛型的,因此可以根据传入的具体类型自动推断 Data 字段的类型,避免了显式类型转换的麻烦。
  3. 更加灵活的数据处理:由于 Data 字段的类型是泛型的,可以根据不同的需求传入不同的数据类型,更加灵活地处理数据。例如,可以将 Data 字段的类型指定为 []map[string]any,然后将一个包含多个字典类型的数组赋值给 Data 字段,可以很方便地处理多个字典类型的数据。

华贵铂金

结合实际业务与 RESTful API 响应规范,梳理如下。

response.go 文件

package response

import (
    "net/http"

    . "your-project/common/errorx"

    "github.com/gin-gonic/gin"
)

type Responder interface {
    // 状态码200,业务成功
    Ok()
    // 状态码200,业务成功,有 data 实体
    OkWithData(data any)
    // 状态码200,业务失败,标准 errorx 输出
    Fail(code int)
    // 状态码200,业务失败,自定义 errorx 输出
    FailWithMsg(code int, msg string)
    // 状态码200,业务失败,标准 errorx 输出 + 自定义详细信息
    FailWithDetail(code int, detail string)
    // 状态码302,路由重定向
    Redirect(url string)
    // 状态码404,路由未找到
    NoRoute()
    // 状态码500,服务端致命错误,标准 errorx 输出 + 自定义详细信息
    Fatal(code int, detail string)
}

type Ctx struct {
    GetCtx *gin.Context
}

func Mount(c *gin.Context) *Ctx {
    return &Ctx{GetCtx: c}
}

type Response struct {
    Code   int    `json:"code"`
    Msg    string `json:"msg"`
    Data   any    `json:"data,omitempty"`
    Detail string `json:"detail,omitempty"`
}

func result(c *gin.Context, status, code int, msg, detail string, data any) {
    switch status {
    case http.StatusOK:
        c.JSON(status, &Response{
            Code:   code,
            Msg:    msg,
            Data:   data,
            Detail: detail,
        })
        break
    case http.StatusNotFound:
        c.AbortWithStatusJSON(status, &Response{
            Code: code,
            Msg:  msg,
        })
        break
    case http.StatusInternalServerError:
        c.AbortWithStatusJSON(status, &Response{
            Code:   code,
            Msg:    msg,
            Detail: detail,
        })
        break
    case http.StatusFound:
        c.Redirect(status, data.(string))
        break
    default:
        c.AbortWithStatusJSON(status, &Response{
            Code:   code,
            Msg:    msg,
            Detail: detail,
        })
        break
    }
}

// [200] - 0 - data (is null)
func (c *Ctx) Ok() {
    result(c.GetCtx, http.StatusOK, CodeSuccess, GetMsg(CodeSuccess), "", (*any)(nil))
}

// [200] - 0 - data (has entity)
func (c *Ctx) OkWithData(data any) {
    result(c.GetCtx, http.StatusOK, CodeSuccess, GetMsg(CodeSuccess), "", data)
}

// [200] - 非0 - code&msg (built-in)
func (c *Ctx) Fail(code int) {
    result(c.GetCtx, http.StatusOK, code, GetMsg(code), "", nil)
}

// [200] - 非0 - code&msg (customize)
func (c *Ctx) FailWithMsg(code int, msg string) {
    result(c.GetCtx, http.StatusOK, code, msg, "", nil)
}

// [200] - 非0 - code&msg (built-in) detail (customize)
func (c *Ctx) FailWithDetail(code int, detail string) {
    result(c.GetCtx, http.StatusOK, code, GetMsg(code), detail, nil)
}

// [302] - 非0 - data (redirect)
func (c *Ctx) Redirect(url string) {
    result(c.GetCtx, http.StatusFound, CodeSuccess, GetMsg(CodeTemporaryRedirect), "", url)
}

// [404] - 非0 - code&msg (built-in)
func (c *Ctx) NoRoute() {
    result(c.GetCtx, http.StatusNotFound, CodeRequestInvalidUrlPath, GetMsg(CodeRequestInvalidUrlPath), "", nil)
}

// [500] - 非0 - code&msg (built-in) detail (customize)
func (c *Ctx) Fatal(code int, detail string) {
    result(c.GetCtx, http.StatusInternalServerError, code, GetMsg(code), detail, nil)
}

main.go 文件

package main

import (
    . "your-project/common/errorx"
    . "your-project/common/response"    

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        mnt := Mount(c)
        // 业务逻辑
        if BusinessOK {
            Responder(mnt).OkWithData(map[string]any{
                "name":       "Pokeya",
                "age":        30,
                "male":       true,
                "job":        "developer",
                "tech_stack": "backend",
            })
        } else {
            Responder(mnt).FailWithDetail(CodeRequestInvalidQuery, "未找到 user_id 请求参数")
        }
    })
    _ = router.Run(":8080")
}

点评:

  • 这段代码的整体结构清晰、函数接口定义明确,对于使用者来说比较友好。
  • 当然还有一些可以改进的地方 ...

璀璨钻石

实际上,上述写法都比较基础,没有使用较为高级的编程技巧。

秉承着开源与知识分享的精神,嗯🤔,后面更高阶方式就是付费内容了...

哈哈哈,今天的分享就止于此了,下回见 👋

超凡大师

欢迎留言,各路神仙指教,讨论更优的写法 ...