前后端分离架构

前后端分离可能遇到的问题以及解决方案

准备工作

  • 前端人员是否充足,可能前端人员压力增加
  • 前后端职责如何分配,避免职责不明,比如文件上传,缓存由哪一层实现
  • 前后端团队分工协作,避免相互等待,反而不如传统模式

实现介绍

后端专注于:后端控制层(Restful API) & 服务层 & 数据访问层;

前端专注于:前端控制层(Nodejs) & 视图层

前后端分离模式:

  1. 项目设计阶段,前后端架构负责人将项目整体进行分析,讨论并确定API风格、职责分配、开发协助模式,确定人员配备;设计确定后,前后端人员共同制定开发接口。
  2. 项目开发阶段,前后端分离是各自分工,协同敏捷开发,后端提供Restful API,并给出详细文档说明,前端人员进行页面渲染前台的任务是发送API请(GET,PUT,POST,DELETE等)获取数据(json,xml)后渲染页面。
  3. 项目测试阶段,API完成之前,前端人员会使用mock server进行模拟测试,后端人员采用junit进行API单元测试,不用互相等待;API完成之后,前后端再对接测试一下就可以了,当然并不是所有的接口都可以提前定义,有一些是在开发过程中进行调整的。
  4. 项目部署阶段,利用nginx 做反向代理,即Java + nodejs + nginx 方式进行。

从经典的JSP+Servlet+JavaBean的MVC时代,到SSM(Spring + SpringMVC + Mybatis)和SSH(Spring + Struts + Hibernate)的Java 框架时代,再到前端框架(KnockoutJS、AngularJS、vueJS、ReactJS)为主的MV*时代,然后是Nodejs引领的全栈时代,技术和架构一直都在进步。

技术问题

跨域问题

所谓跨域,英文叫做cross-domain,是网络安全领域的一个专有名词。简单点理解就是某些操作越过了域名的界限,访问了别的域名。
如果脚本可以自由访问其他域,就会产生很多安全问题。
比如,假设有一个网上银行系统,你已经登录过了,它支持一个ajax api可以进行转账;有一个论坛系统,人气很高,但是其中有恶意脚本,这个脚本会调用这个ajax api,从当前登录的用户账户中,转1000块到攻击者的账户。这样,当你访问这个论坛的时候,就会被转走1000块,而你一点都不知道!
除此之外,跨域请求还有很多危害。这不是一本关于安全的书,也就不展开讲了,想深入了解的可以买一本余弦编写的《Web前端黑客技术揭秘》。
为了防范跨域攻击,所有现代浏览器都遵循一套同源策略。根据MDN上的定义,“如果两个页面拥有相同的协议(protocol),端口(如果指定),和主机,那么这两个页面就属于同一个源(origin)”。对于违反同源策略的请求,除了img src等少数嵌入操作之外,都会被浏览器阻止。
这里需要注意的是:同源不仅仅要求相同的域名或ip,连协议和端口也必须相同。比如https://localhosthttp://localhosthttp://localhost:3000就不是同源的,而http://localhost/apihttp://localhost/views是同源的。

同源策略限制以下几种行为: 1.) Cookie、LocalStorage 和 IndexDB 无法读取 2.) DOM 和 Js对象无法获得 3.) AJAX 请求不能发送

我们平常所说的“跨域”其实就是指“发起不同源请求”,而这样的跨域请求会被浏览器阻止。
同源策略对保障互联网安全有着非常重要的作用,很多安全策略都是基于同源策略的。但是,这种同源策略会对前后端分离架构下的开发过程带来很大困扰。比如,即使是本地服务器,也没法和前端开发服务器运行在同一个端口上,这时候,跨域是必然的。而如果要让后端程序同时提供web服务,则很难发挥前端工具链的轻量级优势。那么,如何解决跨域问题呢?

cookies跨域

跨域访问的时候,浏览器默认是不带cookie去的,这样导致每次后台都产生根据请求产生新的session。
跨域是不会带cookies的,所以要使用coodies session模式,需要前后端部署在一个tomcat里面。在开发过程中可能,前端人员需要直接范围后端接口,这就需要特殊处理,登录成功后后端返回sessionId,前端把sessionid放在cookies中,因为前端使用的是localhost,访问后端的IP,登录成功后后端返回的cookies跟前端的localhost跨域被浏览器清除。如果把localhost换成跟后端一样的域名还是可以范围的,如果要范围第三方的应用接口就不行了,范围的url跟浏览器中访问的url域名肯定不一致,要保证一致可以使用ng反向代理。

想用cookies,要保证同域,加上反向代理

其他解决方案,服务器浏览器都设置允许跨域:

1
2
服务器:Access-Control-Allow-Credentials: true
浏览器:withCredentials = true

cookies存在客户端也不安全
session消耗服务器内存性能
普通token方式:生成token存在数据库中,后面带上token校验增加数据库压力
使用cookies模式状态信息session要存在服务器,增加服务器压力,后面出现了JTW token模式,把状态信息存在客户端,服务端只要解析出token中的信息,消耗服务器计算压力。

Token与cookie相比较的优势:

  1. 支持跨域访问 ,将token置于请求头中,而cookie默认是不支持跨域访问的
  2. 无状态化,服务端无需存储token,只需要验证token信息是否正确即可,而session需要在服务端存储,一般是通过cookie中的sessionID在服务端查找对应的session
  3. 无需绑定到一个特殊的身份验证方案(传统的用户名密码登陆),只需要生成的token是符合我们预期设定的即可
  4. 更适用于移动端(Android,iOS,小程序等等),像这种原生平台不支持cookie,比如说微信小程序,每一次请求都是一次会话,我们可以每次去手动为他添加cookie
  5. 避免CSRF跨站伪造攻击 ,还是因为不依赖cookie
  6. 非常适用于RESTful API ,这样可以轻易与各种后端(java,.net,python…)相结合,去耦合

使用token模式浏览器一般把token存储在localStorage里

JSONP方式

最初用来解决跨域问题的方式,叫做JSONP,它的基本原理是:跨域的“资源嵌入”是被浏览器允许的。所以,可以通过一个script标签来嵌入一段来自其他服务器的脚本。由于这个脚本完全运行在当前域,无法访问第三方服务器的cookie等敏感信息,所以是安全的。
JSONP的缺点是它只能支持GET操作,没法支持POST等操作,但是由于兼容性好等优点,仍然有很多网站采用JSONP的方式公开自己的API供第三方调用。
在Angular中,$http内置了对JSONP的支持,它的调用接口也和其他方法没什么区别,使用起来非常简单。

反向代理方式

要想解决跨域问题,最简单彻底的方法当然是把他们拉到一个域下,而这就是该“反向代理”发挥作用的时候了。
所谓反向代理,就是在自己的域名下架设一个Web服务器,这个服务器会把请求转发给第三方服务器,然后把结果返回给客户端。
这时候,在客户端看来,自己就是在和这台反向代理服务器打交道,而不知道第三方服务器的存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
server {
listen 80;
server_name your.domain.name;
location / {
proxy_pass http://localhost:5000/; # 把根路径下的请求转发给前端工具链(如gulp)打开的开发服务器,如果是产品环境,则使用root等指令配置为静态文件服务器
}
location /api/ {
proxy_pass http://localhost:8080/service/; # 把 /api 路径下的请求转发给真正的后端服务器
proxy_set_header Host $http_host; # 把host头传过去,后端服务程序将收到your.domain.name,否则收到的是localhost:8080
proxy_cookie_path /api /service; # 把cookie中的path部分从/api替换成/service
proxy_cookie_domain localhost:8080 your.domain.name; # 把cookie的path部分从localhost:8080替换成your.domain.name
}
}

注意最后这两句话,由于cookie中存在一个path机制,可以对同一个域下的不同子域进行区分。所以,如果后端所使用的路径是/service,而前端使用的路径是/api,那么前端将不能访问后端的cookie,这就导致登录等操作所写入的cookie无法正常传入传出,其表现则是登录始终没有效果。cookie的domain机制也是类似的原理。

现实中的后端服务器,使用path机制的很多,所以这项设置非常实用。原先放在tomcat的前端页面要统一放在nginx里了统一端口。还有一种是返回头增加允许跨域字段,这样就可以前端部署在tomcat里,通过nginx转发请求。还有一种实现方式是直接自己写一个代理类放在tomcat里面,进行转发请求。

CORS方式

CORS的原理是基于服务方授权的模式,也就是说提供服务的程序要主动通过CORS回应头来声明自己信任哪些源(协议+域名+端口)。 由于得到了服务方的授权,浏览器就可以放行来自这些域的请求了。

具体解决方法

前端webpack打包运行的时候会启动nodejs的服务器占用8080端口,后端springboot自带tomcat启动占用8888端口。导致前端请求的ajax到后台会产生跨域问题,Nodejs使用的是8080端口AJAX只能请求8080的端口,如果请求8888就跨域了会被浏览器禁止。解决方法:

使用Spring拦截器进行处理

1
2
3
4
5
6
7
8
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse response, Object o) throws Exception {
response.addHeader("Access-Control-Allow-Origin", "*");
response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD");
response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.addHeader("Access-Control-Max-Age", "3600");
return true;
}

@CrossOrigin注解

1
2
3
4
@CrossOrigin(value = "*", allowCredentials = "true")
public class BaseController {

}

使用nginx作为反向代理

这是一种比较好的解决办法,因为使用nginx作为反向代理的时候前端用户浏览器访问的是nginx的地址,是一个地址,ajax请求的地址也是这个地址,只是在nginx里配置了去找后台的api.所以没有跨域的问题的

首先设置nginx代理所有请求

1
2
3
4
5
6
7
8
server {
listen 1112;
server_name 127.0.0.1;

location / {
proxy_pass http://127.0.0.1:8080/;
}
}

比如监听1112端口,所有请求都转发到8080的前端nodejs端口

然后再配置后台数据的接口,比如/api/开头的请求都转发给springboot后台8888端口

1
2
3
location /api/ {
proxy_pass http://127.0.0.1:8888/;
}

那么这样做的话需要前端代码里所有的ajax请求都加上api开头前缀,所以需要统一配置下

1
2
3
4
5
6
7
8
9
10
const ajaxUrl = env === 'development'
? '/api'
: env === 'production'
? 'https://www.url.com'
: 'https://debug.url.com';

util.ajax = axios.create({
baseURL: ajaxUrl,
timeout: 30000
});

原先放在tomcat的前端页面要统一放在nginx里了统一端口。还有一种是返回头增加允许跨域字段,这样就可以前端部署在tomcat里,通过nginx转发请求。还有一种实现方式是直接自己写一个代理类放在tomcat里面,进行转发请求。

对于客户端要范围不同源的数据网络服务

  • 可以搭建一个代理服务器,代理服务器设置成允许跨域CORS方式。这样所有客户端都可以访问这个代理服务器不会出现跨域问题。
  • 然后代理服务器负责发送请求向目标网络服务器获取信息
  • 获取到数据后返回浏览器客户端

如果浏览器直接访问网络服务,由于网络服务不允许跨域访问,浏览器会拦截我们的请求

前端代码写死IP地址问题

如果前端代码写死了IP地址,在服务器有多网卡,内外网使用的情况下会出现无法使用的问题。建议前端通过浏览器地址获取请求的IP或者域名,或者直接使用固定域名访问。

业务沟通问题

API协作

优秀的API设计来自于迭代过程,包括草图设计、原型设计以及实现。

成功的API设计意味着要设计出一种接口,让它的使用方式符合它的目的。作为API设计者来说,我们所做的每个决策都会影响到产品的成败。设计过程中需要做出一些重大的决策,例如API所使用的传输协议、或它所支持的消息格式。但除此之外,还有许多相关的次要决定,例如接口的控制、名称、关联以及次序。而当你将所有的决策集中在一起时,它们将带动API的使用模式。如果你能够做到每个决定都是正确的,那么这种模式将对API产生完美的支持与促进作用。
如果你要做出一个正确的设计决策,很可能会先做出一个错误的决策,并从中吸取经验教训。实际上,你很可能会在多次犯错之后才能够接近正确的决策。这也正是迭代的关键所在,因为没有人能够做到一次成功,但只要有足够的机会,你就能够做到接近完美。

通过迭代方式进行API设计,这一点说起来容易,但在实际应用中做到这一点并不简单。我们所面临的一个常见的挑战在于,在某个API发布之后再进行变更是非常困难的。事实上,对一个使用中的API进行变更的代价很大,并且伴随着很大的风险。或者借用Joshua Bloch的说法:“公开的API就像钻石,它是永恒不变的。”

  • 草稿设计,这份词汇表为我们提供了一个基础,我们可以从它出发为API中的资源与关联设计草图,内容可以包括URI、资源名称、资源间的关联、链接文本以及其它结构化以及导航元素。请再次注意,没有必要画出草图的所有细节,我们的目标是表达出API里最重要的部分。
    最重要的一点在于,最初的草图无需过于深入。比方说,请尽量避免在这一阶段就深入到错误流的建模,或响应消息元素的设计。这些部分可以稍后再加入,或者可以为它们进行专门的草图设计。
    一份单独的草图无需反映出整个接口,实际上,为某些细节部分专门设计草图的方式可能更实用。举例来说,我们可以设计一个基本错误流的草图,它与整个API都具有相关性,或是设计一种响应消息格式的草图,这种格式可以应用到所有响应中。之后,在原型设计阶段,我们可以将这些思想应用到某个工作模型中。
  • 原型设计,在原型设计阶段,我们将有机会为接口设计一个具有更高保真度的模型,并且对草图设计阶段产生的一些假设进行验证。一个优秀的API原型应当是可以调用的,它应当能够处理真实的请求消息,并在必要时提供响应。开发者甚至应该能够通过使用这个原型API,创建出一个简单的应用。不过,创建原型的成本应当低于一个完整的实现。有一种方法可以保持较低的成本,即模拟响应的消息,而不是由后台系统输出真实的响应消息。这种方式有时也称为接口的虚构(mocking),它是一种建立快速原型的好方法。
  • 实现,实现者的任务是将一个原型化的接口转变成一种可以放心地进行实际应用的产品。交付一个安全的、可靠的、以及可伸缩的实现是一个很大的挑战,这一过程本身也需要经历一种专门的设计流程。最后的原型以及支持性的草图描述了接口应该表现出的样子,它们反映出了所有的设计决策,并形成了一份规格,说明了具体需要创建什么样的产品。实际上,可以使用一种正式的接口描述语言(或称IDL),从原型阶段自然地过渡到实现阶段。
    举例来说,如果你对原型API感到满意,就可以选择以一种API Blueprint文档(或Swagger、RAML、WADL,又或者是其它任何一种最适合你工作环境的格式)对其进行描述。

通过工具描述

Apiary为Blueprint语言所提供的编辑器有很强的竞争力,因为它提供了一套完整的工作流工具以支持设计过程。只要以Blueprint编写一个简单的API描述,设计师就能够对文档进行评估、调用原型,甚至对调用过程进行分析。

使用可视化工具进行草图设计

目前比较流行的有三种方案来解决前后端协作的问题:

  1. 基于注释的 API 文档:这是一种通过代码中注释生成 API 文档的轻量级方案,它的好处是简单易用,基本与编程语言无关。因为基于注释,非常适合动态语言的文档输出,例如 Nodejs、PHP、Python。由于NPM包容易安装和使用,这里推荐 nodejs 平台下的 apidocjs。
  2. 基于反射的 API 文档:使用 swagger 这类通过反射来解析代码,只需要定义好 Model,可以实现自动输出 API 文档。这种方案适合强类型语言例如 Java、.Net,尤其是生成一份稳定、能在团队外使用的 API 文档。
  3. 使用契约进行前后端协作:在团队内部,前后端协作本质上需要的不是一份 API 文档,而是一个可以供前后端共同遵守的契约。前后端可以一起制定一份契约,使用这份契约共同开发,前端使用这份契约 mock API,后端则可以通过它简单的验证API是否正确输出。

基于注释的API文档

apidocjs 是生成文档最轻量的一种方式,apidoc 的缺点是需要维护一些注释
安装:

1
npm install apidoc -g

最小化运行:

1
apidoc -i myapp/ -o apidoc

基于反射的API文档

swagger 实际上是一整套关于 API 文档、代码生成、测试、文档共享的工具包包括 :

  1. Swagger Editor 使用 swagger editor 编写文档定义 yml 文件,并生成 swagger 的 json 文件
  2. Swagger UI 解析 swagger 的 json 并生成 html 静态文档
  3. Swagger Codegen 可以通过 json 文档生成 Java 等语言里面的模板文件(模型文件)
  4. Swagger Inspector API 自动化测试
  5. Swagger Hub 共享 swagger 文档

基于契约的前后端协作

传统的方式往往是服务器开发者完成了 API 开发之后,前端开发者再开始工作,在项目管理中这样产生时间线的依赖。理想的情况下,在需求明确后,架构师设计,前后端应该能各自独立工作,并在最后进行集成测试即可。可以关注:

  • Spring cloud contract
  • Swagger Yaml 契约
  • RAML 契约

管理契约文件,使用文档服务器。

接口管理平台-YAPI、RAP、Nei

JWT技术

  • 服务端无状态
  • jwt 的 payload 大部分不需要存储在 redis 里,因为可以用签名来验证,真正需要的只有一个 uuid;而session共享要全都存储。存储成本小
  • jwt 只需要判断一 exist,session 共享需要 get。带宽压力小
  • 比如更新 payload 字段需要重新签发,浏览器不会自动发送 Authorization header
  • 假设现在有一个APP,后台是分布式系统。APP的首页模块部署在杭州机房的服务器上,子页面模块部署在深圳机房的服务器上。此时你从首页登录了该APP,然后跳转到子页面模块。session在两个机房之间不能同步,用户是否需要重新登录?传统的方式(cookie+session)需要重新登录,用户体验不好。session共享(在多台物理机之间传输和复制session)方式对网络IO的压力大,延迟太长,用户体验也不好。JWT相当于将session保存在了客户端,解决了后台session复制的问题

jwt的优点:

  1. 可扩展性好
    应用程序分布式部署的情况下,session需要做多机数据共享,通常可以存在数据库或者redis里面。而jwt不需要。
  2. 无状态
    jwt不在服务端存储任何状态。RESTful API的原则之一是无状态,发出请求时,总会返回带有参数的响应,不会产生附加影响。用户的认证状态引入这种附加影响,这破坏了这一原则。另外jwt的载荷中可以存储一些常用信息,用于交换信息,有效地使用 JWT,可以降低服务器查询数据库的次数。

jwt的缺点:

  1. 安全性
    由于jwt的payload是使用base64编码的,并没有加密,因此jwt中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全。
  2. 性能
    jwt太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致jwt非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以jwt一般放在local storage里面。并且用户在系统中的每一次http请求都会把jwt携带在Header里面,http请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用jwt的http请求比使用session的开销大得多。
  3. 一次性
    无状态是jwt的特点,但也导致了这个问题,jwt是一次性的。想修改里面的内容,就必须签发一个新的jwt。
  4. 无法废弃
    通过上面jwt的验证机制可以看出来,一旦签发一个jwt,在到期之前就会始终有效,无法中途废弃。例如你在payload中存储了一些信息,当信息需要更新时,则重新签发一个jwt,但是由于旧的jwt还没过期,拿着这个旧的jwt依旧可以登录,那登录后服务端从jwt中拿到的信息就是过时的。为了解决这个问题,我们就需要在服务端部署额外的逻辑,例如设置一个黑名单,一旦签发了新的jwt,那么旧的就加入黑名单(比如存到redis里面),避免被再次使用。
  5. 续签
    如果你使用jwt做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变jwt的有效时间,就要签发新的jwt。最简单的一种方式是每次请求刷新jwt,即每个http请求都返回一个新的jwt。这个方法不仅暴力不优雅,而且每次请求都要做jwt的加密解密,会带来性能问题。另一种方法是在redis中单独为每个jwt设置过期时间,每次访问时刷新jwt的过期时间。

可以看出想要破解jwt一次性的特性,就需要在服务端存储jwt的状态。但是引入 redis 之后,就把无状态的jwt硬生生变成了有状态了,违背了jwt的初衷。而且这个方案和session都差不多了。

JWT和Session方式存储id的差异
Session方式存储用户id的最大弊病在于Session是存储在服务器端的,所以需要占用大量服务器内存,对于较大型应用而言可能还要保存许多的状态。一般而言,大型应用还需要借助一些KV数据库和一系列缓存机制来实现Session的存储。
而JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。除了用户id之外,还可以存储其他的和用户相关的信息,例如该用户是否是管理员、用户所在的分组等。虽说JWT方式让服务器有一些计算压力(例如加密、编码和解码),但是这些压力相比磁盘存储而言可能就不算什么了。具体是否采用,需要在不同场景下用数据说话。

单点登录
Session方式来存储用户id,一开始用户的Session只会存储在一台服务器上。对于有多个子域名的站点,每个子域名至少会对应一台不同的服务器,例如:www.taobao.com,nv.taobao.com,nz.taobao.com,login.taobao.com。所以如果要实现在login.taobao.com登录后,在其他的子域名下依然可以取到Session,这要求我们在多台服务器上同步Session。使用JWT的方式则没有这个问题的存在,因为用户的状态已经被传送到了客户端。

JWT+Shiro

如果要保存用户的权限,可以使用jwt+shiro+redis维护token 的方案。

扩展

Session

session-cookies关联:后台通过setcookies写入sessionid。浏览器后续请求在cookies中带上sessionid进行后端session与浏览器端关联
Session创建:在server调用HttpServletRequest.getSession(true) 或者 HttpServletRequest.getSession()的时候。使用JWT等不带cookies,应该避免服务器每次都创建session。JSP页面有个session=“false“就是为了通过显示创建session,设置后web后台不会自己创建session,必须通过HttpServletRequest.getSession()显示创建session
对于浏览器禁用cookies:利用URL重写实现Session跟踪也就是sessionid跟在url后面。重写HttpServletResponse中的encodeURL、encodeRedirectURL
application对象:所有客户端共享一个application
session对象:各个客户端对应自己的session对象
不想用tomcat创建的session怎么办: shiro 已经实现了:publi class ShiroHttpServletRequest extends HttpServletRequestWrapper { 。自己也可以重写HttpServletRequestWrapper.getSession()

开发参考

可以把cookies id放在head中传输

发展过程

session→token jwt(HMAC-SHA256算法签名)

参考