前言
聊完追梦App的设计思路后我们来聊聊后端架构。
一、架构选择
此时我们面临几种架构选择。
单体架构
单体架构是最简单的软件架构,常用于传统的应用软件开发以及传统Web应用。传统Web应用,一般是将所有功能模块都打包(jar,war)在一个Web容器(JBoss、Tomcat)中部署、运行。随着业务复杂度增加、技术团队规模扩大。在一个单体应用中维护代码,会降低开发效率。即使是处理一个小需求,也需要将所有机器上的应用全部部署一遍,增加了运维的复杂度。
服务化架构(SOA架构)
当某一天使用单体架构发现很难推进需求的开发、以及日积月累的技术债,很多企业会开始做单体服务的拆分。拆分的方式一般有水平拆分以及垂直拆分。垂直拆分把一个应用拆成松耦合的多个独立的应用,让应用可以独立部署,有独立的团队进行维护。水平拆分把一些通用的,会被很多上层服务调用的模块独立拆分出去,形成一个共享的基础服务,这样拆分可以对一些性能瓶颈的应用进行单独的优化和运维管理,也一定程度防止了垂直拆分的重复造轮子。
SOA也叫面向服务的架构,从单体服务到SOA的演进,需要结合水平拆分以及垂直拆分。SOA强调用统一的协议进行服务间的通信,服务间运行在彼此独立的硬件平台但是需通过统一的协议接口相互协作,也即将应用系统服务化。举个易懂的例子,单体服务如果相当于一个快餐店,所有的服务员都是一样的,又要负责收银结算,又要负责做汉堡,又要负责端盘子,又要负责打扫,服务员之间不需要有交流,一个用户来了一个服务员从前到后负责到底。SOA相当于让服务员有职责分工,收银员负责收银,厨师负责做汉堡,保洁阿姨负责打扫等等。所有服务员需要同一种语言交流,方便工作协调。
微服务架构
微服务可以在“自己的程序”中运行,并通过“轻量级设备与HTTP型API进行沟通”。关键在于该服务可以在自己的程序中运行。通过这一点我们就可以将服务公开与微服务架构(在现有系统中分布一个API)区分开来。在服务公开中,许多服务都可以被内部独立进程所限制。如果其中任何一个服务需要增加某种功能,那么就必须缩小进程范围。在微服务架构中,只需要在特定的某种服务中增加所需功能,而不影响整体进程的架构。
首先,我们要明确的一点是——脱离实际业务谈架构的都是耍流氓!
我们先回顾下设计思路,我们要完成哪些功能。
追梦体系、个人账户成长系统、任务系统、虚拟货币系统、社交系统、学习系统、学习工具…
可以看到我们如果要把这款软件做好的话,实际上体量还是有点大的,而且业务对服务器要求比较高,这样一看,分布式微服务确实是个不错的选择。
诚然,分布式微服务架构确实是个不错的选择,但我们也该考虑自己的实际情况:
1.这款App首先是去参加比赛的,所以必须快速出产品,最起码要把基本功能做完。
2.分布式微服务的成本太高,我们大学生暂时无力承担服务器费用。
3.初期用户量小,分布式微服务并不合适。
4.小组成员技术参差不齐,学过分布式微服务的就只有我一个。
综上,分布式微服务对于初期的开发来说并不合适,单体架构对于初期的我们来说会更合适些。但我们就不用分布式微服务去架构吗?
不,初期确实不适合用分布式微服务去开发,但是等到我们做出点成绩后,等到我们App用户量增大的时候,我们再去拆分服务,用分布式微服务重构项目。
这确实会花费些时间,但只要我们前期有拆分服务的思想,有意的去区分服务,这样后期拆分起来也会容易很多。
二、技术选型
前端:Flutter
后端框架:SpringBoot、SSM
数据库:mysql
数据库连接池:Druid
服务器:Tomcat
JDK:8
开发工具:IDEA
三、项目结构
之前架构选择中说到我们选择单体架构,但是又要有服务化的趋势,这样才能让服务拆分变得简单容易。
那么怎么设计呢?
初步想法是分业务大致拆分出来,有人可能马上想到用多模块,因为多模块结构清晰,可以很好的区分业务。
之前我也曾想过单体架构多模块项目的结构,但我感觉其实没有必要,既然不是分布式微服务架构,就没必要分模块,因为这要会增加模块间交互的成本,得不偿失。
多包完全可以达到之前想要的效果。
所以我计划如下图这样来构建项目:
通过业务来区分包名:
common:存放实体类以及一些公共的接口文件
blog:发布博客、动态的业务
dream:追梦体系
social:社交业务
study:学习系统业务
user:个人管理中心,用来管理账户信息
(开发可能不完全按照这个,因为我总感觉这样弄怪怪的)
四、数据库表设计
此部分日后再补,需要和组员讨论一下。
五、前后端交互规范
以下摘自前后端接口规范
具体需要定义哪些接口, 可以按照下面的思路来整理
资源接口: 系统涉及到哪些资源, 按照 RESTful 方式定义的细粒度接口
操作接口: 页面涉及到哪些操作, 例如修改购物车中商品的数量, 更换优惠券等等, 也可以使用 RESTful 方式来定义
页面接口: 页面涉及到太多接口, 如果是一个个地调用, 会需要很多次请求, 有可以影响到前端的性能和用户感知(特别是首屏的体验), 因此可能需要将这些接口的数据合并到一起, 作成一个聚合型接口提供给前端来使用
接口协商要点
接口必须返回统一的数据结构, 参考后端接口通用规范中接口返回的数据结构
接口查询不到数据时, 即空数据的情况下返回给前端怎样的数据 * 建议返回非 null 的对应数据类型初始值, 例如对象类型的返回空对象({}), 数组类型的返回空数组([]),
其他原始数据类型(string/number/boolean…)也使用对应的默认值 *
这样可以减少前端很多琐碎的非空判断, 直接使用接口中的数据 * 例如: result.fieldName * 如果
result 为 null, 可想而知会报错 Uncaught TypeError: Cannot read property 'fieldName' of null
调用接口业务失败的常用错误码, 例如未授权时调用需要授权的接口返回 "status": 1
接口需要登录时如何处理, 特别是同时涉及到 Web 端/微信端/App 端, 需要前端针对运行环境判断如何跳转到登录页面
返回数据中图片 URL 是完整的还是部分的 * http://a.res.com/path/to/img.png 这就是完整的, 前端直接使用这个 URL * /path/to/img.png 这就是部分的, 一般省略域名部分, 前端需要自己拼接后才能使用
'http://a.res.com' + '/path/to/img.png'
返回数据中页面跳转的 URL 是给完整的还是部分的 * 内部页面返回部分的, 或者只给ID, 由前端自己拼接, 例如只给出商品ID, 让前端自己拼接商品详情页的 URL * 外部页面返回完整的, 例如广告位要跳转去谷歌
返回数据中日期的格式, 是使用时间戳还是格式化好的文字 * 对于需要前端再次处理的日期值(例如根据日期计算倒计时), 可以使用时间戳(简单暴力), 例如: 1458885313711, 或者参考
Date.prototype.toJSON
提供 ISO 标准格式(例如需要考虑时区时) * 对于纯展示用的日期值, 推荐返回为格式化好的文字, 例如: 2017年1月1日
对于大数字(例如 Java 的 long 类型), 返回给前端时需要设置为字符串类型, 否则 JavaScript 会发生溢出, 造成得到的数值错误 * 例如: 返回 JSON 数据 {"id": 362909601374617692} 前端拿到的值却是:
362909601374617660
分页参数和分页信息 * 如何限制只返回 N 条数据(limit 参数) * 如何控制每页的数据条数(pageSize 参数) * 如何加载某一页的数据(page 参数)
第一页是从 0 开始还是从 1 开始 * 如何避免无限滚动加载可能出现的重复数据(采用 lastId 分页方式, 来避免传统分页方式的弊端)
假设数据是按照新增时间倒序排列的
首先加载 2 页的数据
等了很久
期间新增了很多数据
再获取第 3 页数据
此时就可能出现重复数据的情况, 因为新增的数据都排在最前面, 后面会接着已经加载过数据 * 分页信息包含什么(total, page, pageSize) * 分页信息何时表明已经是最后一页了
请求某页数据时返回的数据条数 < pageSize
请求某页数据时返回的数据条数 = 0
如果碰巧最后一页有 pageSize 条数据, 前端无法通过数据条数来判断已经处于最后一页了
接口定义
所有的接口定义在项目前端静态文件目录的 _mockserver.json 文件中, 启动 puer-mock 服务,
即可使用这些接口获得符合规范的假数据, 也可以查看接口文档.
具体 puer-mock 的详细使用手册和 _mockserver.json 如何配置接口请参考 puer-mock
项目, 或者参考项目中已经配置好的其他接口.
接口协作
由于接口规范的定义和接口的实际实现是分开的两个部分, 而且涉及到多人协作, 因此在开发过程中可能出现接口规范与实现不同步,
最终造成实际的接口不符合规范的定义, 接口规范就会慢慢失去存在的意义.
为了尽量避免这种问题, 后端在实现接口的过程中应该确保与接口规范保持一致, 一旦出现分歧, 必须同步修改接口规范, 尽可能保持沟通。
后端接口通用规范
接口地址和请求方式
接口根路径 - Root
Endpoint 推荐为:
http://api.yourdomain.com 或者 http://yourdomain.com/api
接口地址即接口的 URL, 定义时使用相对路径(即不用带上域名信息), 建议分模块来定义, 推荐 REST 风格, 例如
GET /user/:id 表示获取用户信息
POST /user 表示新增用户
接口参数
向接口传递参数时, 如果是少量参数可以作为
URL query string 追加到接口的 URL 中, 或者作为 Content-Type: application/x-www-form-urlencoded 放在请求体(body)中(即表单提交的方式)
对于复杂的接口参数(例如嵌套了多层的数据结构), 推荐在 HTTP 请求体(body)中包含一个 JSON 字符串作为接口的参数,
并设置 Content-Type: application/json; charset=utf-8.
例如
查询 VIP 用户的接口 charset=utf-8 { "name": "hanmeimei", "isVip": true } ``` ### 接口返回的数据结构 返回的响应体类型推荐为 `Content-Type: application/json; charset=utf-8`, 返回的数据包含在 HTTP 响应体中, 是一个 JSON Object. 该 Object 可能包含 3 个字段 `data`, `status`, `statusInfo` ```HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "data": {}, "status": 0, "statusInfo": { "message": "给用户的提示信息", "detail": "用于排查错误的详细错误信息" } } ``` **字段名** |**字段说明** :----------|:----------- data | **业务数据**<br>必须是任意 JSON 数据类型(number/string/boolean/object/array).<br>推荐始终返回一个 object (即再包一层)以便于扩展字段.<br>例如: 用户数据应该返回 `{"user":{"name":"test"}}`, 而不是直接为 `{"name":"test"}` status | **状态码**<br>必须是 `>= 0` 的 JSON Number 整数.<ul><li>`0` 表示请求处理成功, 此时可以省略 `status` 字段, 省略时和为 `0` 时表示同一含义.</li><li>`非 0` 表示发生错误时的[错误码](http://open.weibo.com/wiki/Error_code "错误码格式可以参考微博API的 Error code"), 此时可以省略 `data` 字段, 并视情况输出 `statusInfo` 字段作为补充信息</li></ul> statusInfo | **状态信息**<br>必须是任意 JSON 数据类型.<br>推荐始终返回一个 object 包含 `message` 和 `detail` 字段<ul><li>`message` 字段作为接口处理失败时, **给予用户的友好的提示信息**, 即所有给用户的提示信息都统一由后端来处理.</li><li>`detail` 字段用来放置接口处理失败时的详细错误信息. 只是为了方便排查错误, 前端无需使用.</li></ul> 例如 * 接口处理成功时接口返回的数据 ```{ "data": "api result" "status": 0 } ``` * 接口处理失败时接口返回的数据 ```{ "status": 1, "statusInfo": { "message": "服务器正忙", "detail": { "exception": "java.util.List" } } } ``` 这样我们就可以非常容易地通过判断 status 来处理数据了 ```javascript if (!response.status) { // status 为 0 或者没有 status 字段时表示接口成功返回了数据 console.log(response.data); } else { // 失败 console.error(response.status, response.statusInfo); // 统一由服务端返回给用户的提示信息 alert(response.statusInfo.message); } ``` ## 错误码规范: `status` 字段该如何取值 采用前后端分离开发模式的项目越来越多, 前端负责调用后端的接口来展现界面, 如果有界面显示异常, 需要有快速方便的手段来排查线上错误和定位出职责范围 综合了经验总结和行业实践, 最简单有效的手段是制定出一套统一的错误码规范, 协助多方人员来排查出接口的错误 例如 * 用户发现错误, 可以截错误码的图, 就能够提供有效的信息帮助开发人员排查错误 * 测试人员发现错误, 可以通过错误码, 快速定位是前端的问题还是后端接口的问题 因此我们确定提示信息规范为: 当后端接口调用出错时, 接口提供一个用户可以理解的错误提示, 前端展示给用户错误提示和错误码, 给予用户反馈 对于错误码的规范, 参考行业实践, 大致有两种方案 * 做显性的类型区分, 快速定位错误的类别, 例如通过字母划分类型: `A101`, `B131` * [Standard ISO Response Codes](http://www.nexion.co.za/docs/merchant-access/user-manual/17.%20Standard%20ISO%20Response%20codes.pdf) * 固定位数, 设定区间(例如手机号码, 身份证号码)来划分不同的错误类型 * [HTTP Status Code Definitions](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html "Most API services follow the HTTP error code system RFC2616 with ranges of error codes for different types of error") * [System Error Codes](https://docs.microsoft.com/en-us/windows/desktop/Debug/system-error-codes) 具体实践如下 * **错误码固定长度**, 以区间来划分错误类型(例如 HTTP 的状态码) 例如: 10404 表示 HTTP 请求 404 错误, 20000 表示 API 调用失败, 30000 代表业务错误, 31000 表示业务A错误, 32000 表示业务B错误 * **错误码可不固定长度**, 以首字母来划分错误类型, 可扩展性更好, 但实际运作还是需要划分区间 例如: H404 表示 HTTP 请求 404 错误, A100 表示 API 调用失败, B100 表示业务A错误, B200 表示业务B错误 关于错误分类的原则, 我们可以根据发送请求的最终状态来划分 - 发送失败(即请求根本就没有发送出去) - 发送成功 - HTTP 异常状态(例如 404/500...) - HTTP 正常状态(例如 200) - 接口调用成功 - 接口调用失败(业务错误, 即接口规范中 status 非 0 的情况) ### 最终规范 错误码可不固定长度, 整体格式为: `字母+数字`, `字母`作为错误类型, 可扩展性更好, `数字`建议划分区间来细分错误 例如: - `A` for **API**: API 调用失败(请求发送失败)的错误, 例如 `A100` 表示 URL 非法 - `H` for **HTTP**, HTTP 异常状态的错误, 例如 `H404` 表示 HTTP 请求404错误 - `B` for **backend or business**, 接口调用失败的错误, 例如 `B100` 业务A错误, `B200` 业务B错误 - `C` for **Client**: 客户端错误, 例如 `C100` 表示解析 JSON 失败
发送 HTTP 请求 ┌───────────┴───────────┐ 发送成功¹ 发送失败² │ │ ┌──────────┴──────────┐ A 例如: A100 获得 HTTP 响应 无法获得 HTTP 响应³ │ │ HTTP status A 例如: A200 ┌──────────┴──────────┐ HTTP 成功(200-300) HTTP 异常 │ | {data, status, statusInfo} H${HTTP status} 例如: H404 ┌───────────┴───────────┐ 接口调用成功(status:0) 接口调用失败 ┌────────┴────────┐ | 客户端处理出错 客户端处理正常 B${status}${statusInfo.message} 例如: B100 | C 例如: C100
发送成功¹: 服务端收到了 HTTP 请求并返回了 HTTP 响应
发送失败²: HTTP 请求没有发送出去(例如由于跨域被浏览器拦截不允许发送), 未到达服务端(即服务端没有收到这个 HTTP 请求)
无法获得 HTTP 响应³: 服务端收到了请求并返回了响应, 但客户端由于某些原因无法获得 HTTP 响应, 例如请求的超时处理机制 ```
统一错误提示
错误日志 * 接口调用出错(${错误码}) ${HTTP 方法} ${HTTP URL} ${请求参数} ${请求选项} ${请求返回结果} * 例如: 接口调用出错(H404) GET https://domain.com {foo: bar} {option1: 'test'} {status: 404}
给用户的提示消息(参考自 QQ 的错误提示消息) * 提示消息(错误码: xxx)
提示消息和错误码之间用换行隔开 * 错误码整块内容建议弱化使用灰色字 * 例如
规范实现:
weapp-backend-api
接口实现建议
接口实现的大方向建议遵循 RESTful 风格
HTTP 动词: 获取数据用 GET, 新增/修改/发送数据用 POST * 例如: 获取用户数据的接口用 GET, 修改用户数据的接口用 POST
对于资源的操作类型, 使用 HTTP 动词来指定, 减少接口 URL 的数量 * 例如: GET /contact 获取联系人, POST /contact 新增/修改联系人
对外的 ID 字段使用字符串类型 * 特别核心数据的 ID 字段, 不要使用自增的数字类型, 建议使用无规则的字符串类型(例如UUID), 避免核心数据被轻易抓取 * 避免使用大数字类型(Long),
因为前端可能承载不了这个精度而溢出得到另外一个数值 * 例如: Java 中的 Long 类型的数值:
362909601374617692, 作为 JSON 数据返回给前端, 前端拿到的值变成了 362909601374617660
接口字段建议同时给出 ID 字段和用于显示字段, 前端提交数据时只提交 ID 字段 * 例如: {"gender": 1, "genderText": "男"}
图片的 URL 建议返回完整的 URL * 例如: {"pic": "https://domain.com/a.png"}
时间字段建议同时返回时间戳的原始值(或 ISO 标准格式)和用于统一显示的格式化文本 * 由后端接口集中控制各端的显示, 提供的原始值兼顾前端的自定义显示或者计算(例如倒计时)的需求 * 避免每个端(例如H5/APP/小程序)都需要对时间做统一的格式化实现,
一旦需要调整, 需要各个端都调整一遍 * 例如: {"createTime": 1543195480357, "createTimeText": "2018年11月26日"}
统一分页的数据格式 * 分页请求的参数和分页结果的数据结构
针对此,我们后端统一了一个简单的数据响应格式:
package com.dreamchaser.dream.common.utils; import java.io.Serializable; import lombok.Data; /** * 所有服务统一响应数据格式 * * @author ye17186 * @version 2019/2/15 14:40 */ @Data public class RestResponse<T> implements Serializable { /** * 业务数据 */ private Object data; /** * 状态码 */ private int status; /** * 状态信息 */ private StatusInfo statusInfo; } @Data class StatusInfo{ /** * 字段作为接口处理失败时, 给予用户的友好的提示信息, 即所有给用户的提示信息都统一由后端来处理 */ private String message; /** * 字段用来放置接口处理失败时的详细错误信息. 只是为了方便排查错误, 前端无需使用. */ private String detail; }
接口部分需和大家协商制定。这部分以后再补。