前端流行的单页应用(SPA),带来了很多体验上的优化,也带来了很多问题。以往我们每点击一次鼠标,页面就会在后台生成一次,很多事情都会很简单。但是到了单页应用,相当于访问页面的时候只请求了页面一次,后面的数据、表单等都是通过 AJAX 的,页面的渲染由前端完成,给后端带来了很多挑战。
比如说登陆验证,传统的页面每次请求都会经过后端判断一下 Cookie 中携带的信息,如果用户没有登陆,就将用户重定向到一个登陆页面。
但是单页应用一般是页面先渲染出来,然后通过 API 访问后端,虽然鉴权方式还是通过访问 API 的时候携带 Cookie,但是用户将会先看到页面加载出来,然后访问 API 得到 403,跳转到登陆页面,体验就不太友好了。(应该是页面都渲染不出来,直接跳到登陆)。
今天用了一个方法,Nginx 配合后端的应用,来解决了这个问题。这篇文章来分享一下原理。
Before:
- 用户访问页面;
- 用户访问 API;
- API 返回了 403;
- 用户跳转到登陆;
After:
- 用户访问页面;
- 页面判断用户没有登陆,返回302;
- 用户跳转到登陆;
之所以出现这个问题,因为采用了传统的单页应用部署方式,即前后端完全分离。Nginx 负责返回前端页面,应用只是一个 API 服务器。渲染页面这一步应用感知不到。
部署方式
要判断用户是否登录的话,就要让“返回前端HTML”这一步交给应用来做。(假如我们想保持 Nginx 不涉及业务逻辑的话。)有人可能认为这样会很慢,实际上返回HTML这一步是很快的,因为这个 HTML 很小,我们写的前端应用都作为 <link> <script> 等引用外部资源异步加载,而且这些静态资源一般都是有 CDN 的。
真正可能慢的地方是 URL 匹配。我们知道在单页应用中,你访问 /m/foo
和 /m/bar
都是完全相同的一个 HTML,只是前端应用通过 URL 的不同来帮你路由了。但是访问 /api/foo.json
的时候可能真正访问到了服务器。一般来说,什么时候是访问静态页面,什么时候是请求 API,我们是交给 Nginx 配置来处理的,因为 Nginx 是一个专业的 Web 服务器,URL 匹配的效率很高。虽然我没测试过,但是我觉得 Django/Spring 这种 Web 框架,匹配 URL 的速度肯定要比 Nginx 慢很多。
所以这里需要:
- 让应用判断用户是否登录,选择是返回 HTML 还是重定向到登录页面;
- URL 匹配还是在 Nginx 做,如果是前端页面,那么 Nginx 就去掉 Path,直接 proxy_pass / 就可以了。那么应用这边只需要对
/
这个 Path 来返回一个 HTML;
Nginx 相关的配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
server { listen 80 default_server; location ^~ / { rewrite /(.*) / break; proxy_pass http://appserver; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-By $server_addr:$server_port; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Connection ""; } } |
表示正则 match 到 /
开头的话,就用 rewrite 指令去掉 / 后面的内容,将 URL 改写成 /
。然后 break
参数告诉 Nginx 停止处理其他的 rewrite,传给后端的 appserver
。注意这里我们的 rewrite 只是改写了传给应用的 Path,此时 Chrome 浏览器的 Path 还是完整的,所以我们这么做并不影响前端的路由。
后端的应用只要对 /
这个 Path 返回一个 HTML 就好啦。比如说用 Spring 的话,就这么写:
1 2 3 4 5 6 7 8 9 10 11 |
@Controller public class HtmlRender { /** * 凡是页面请求一律返回此 vm,由前端处理页面路由 * @return the string */ @RequestMapping(value = "/", method = RequestMethod.GET) public String doGet() { return "app.vm"; } } |
这个配置其实很灵活。如果你要 /a
是一个 app,/b
是另一个 app 的话,只要将 Nginx 配置替换成西面这样:
1 2 3 4 5 6 7 8 9 10 11 12 |
server { listen 80 default_server; location ^~ /a { rewrite /a/(.*) /a break; proxy_pass http://appserver; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-By $server_addr:$server_port; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Connection ""; } } |
然后应用只渲染 /a
这个 Path 就好啦。
总结下原理就是:Nginx 负责去掉单页应用的 Path,替换成根目录,然后 App 负责判断用户是否登录,如果登录就返回根目录的 HTML,浏览器渲染出来页面。
登陆跳转问题
这里有一个小小的问题,就是后端应用将用户重定向到登陆的回收,会带上一个用户登陆完成之后回到的URL。比如用户访问的是 /hello/bar
,那么我们希望用户登陆完成之后,直接跳转到 /hello/bar
。
但是因为 Nginx 将用户访问的 Path 给去掉了,应用认为用户访问的是 /
,那么用户登陆之后就看到的是首页,这样体验就不太好了。
解决这个问题,一开始我想用 cookie 记录下用户去登陆之前想访问的 URL (也是在传统网页时代,我们经常用的方法)。这样一来就要区分用户什么时候是想访问的 cookie 的 URL(应该重定向),什么时候就是想访问用户输入的 URL (不需要重定向)。还要注意什么时候应该设置 Cookie,什么时候不要设置 Cookie,什么时候清除Cookie。所以这个方案比较复杂。
搜索了一通之后,我发现 Nginx 是可以对代理后面的服务器返回的 Location
Header 进行修改的。
比如我们 proxy_pass 到 appserver,appserver 返回了 302,带有 Location: /abc/de
的 Header,Nginx 可以将 Header 修改成 /foo/bar
,再返回给浏览器。
这个功能就是 proxy_redirect 指令。
配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
server { listen 80 default_server; location ^~ /m { rewrite /m/(.*) /m break; proxy_pass http://appserver; proxy_redirect ~*^(https?://[^/]+/)(.+) $1sourceUrl=http://$http_host$request_uri; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-By $server_addr:$server_port; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Connection ""; } } |
注意这里的第7行,如果应用返回了 Location Header,那么这里就会将应用返回的重定向目标,修改成第二个参数所表示的重定向目标。其中,$http_host
是用户请求的 URL 里面的 Host,$request_uri
是用户请求的原始的 Path。
这样,我们就有了和传统网页一致的登陆体验了。
我知道在vue里有beforeRoute的钩子,可以在这里去请求后端判断登录。它会在路由之前就执行,在渲染之前。
提供另一个可能性
Cool!这样用户就不必看到渲染页面了。
不过感觉有个坏处,就是重定向之前页面需要请求下来,然后再请求一次API。相当于请求了两次。
就算已经登录的情况下,也需要先请求一次API然后才能进行后续的渲染,感觉会拖慢所有页面的打开速度。
所以楼主是想解决:但是单页应用一般是页面先渲染出来,然后通过 API 访问后端,虽然鉴权方式还是通过访问 API 的时候携带 Cookie,但是用户将会先看到页面加载出来,然后访问 API 得到 403,跳转到登陆页面,体验就不太友好了。(应该是页面都渲染不出来,直接跳到登陆)。这个问题,Vue已经有非常成熟方案了,看到你的解决方案,我觉得有点杀鸡用牛刀,还烦请了Nginx,docker化部署会带来很多麻烦。方案:Vue路由导航beforeRoute,登录使用jwt这类token,token存放在localStorage,在beforeRoute先检查token中的过期时间,过期了就跳转行了,只需要2个接口:登录接口返回token、权限获取接口返回过期时间等等。如果有token并且不过期,就直接正常挂载路由,根本不用动nginx。话说文中nginx滥用了,在前后端分离开发模式下,前端和后端耦合越少越好。
是的,是想解决这个问题。也看过 vue 这个方案,我觉得这个是最好的,登陆之后跳回原来的 URL,vue 也可以一并解决,实现起来非常直白,除了每次打开页面多发送一次HTTP请求token(会有点慢?)之外没有任何缺点。奈何我们公司的前端框架是自研的,只能靠 Nginx + 后端应用来解决这个问题了。
我们公司的研发环境和框架结合了,你必须用它这个框架,才能在研发环境中自动帮你 build,部署各个测试环境等。所以我想用 vue 也用不了啊~~
话说感觉楼主很熟悉,好像面试见过……..
哈哈 是吗。你现在也来阿里了?