普通的PC端web程序会话管理是由session进行管理,但对于微信小程序,APP程序,session对其支持是有限的,再加上之前由于整个项目往往都是后端一把抓,后端人员要写前端js,还要写服务端程序,工作量十分庞大。若是由专门的前端人员写页面,要观察页面往往也要重启服务程序,前后端关联太紧密。

前后端分离

由于上述的种种弊端,于是有了前后端分离的架构。前端(pc,小程序,app)都可以共用一个服务端程序。pc端的html可以通过ajax对服务端进行请求,app中也有着封装的api可以通过http协议进行访问,服务端构建起REST风格的api,就可以实现共用后端的需求。

由于之前的会话都是由session进行管理,现在改由token维持会话。下面是客户端与后端进行交互的步骤流程。

前后端分离的后端架构mv 前后端分离 架构_html

  1. 客户端发送账号密码到服务端
  2. 服务端校验通过后生成一个token(唯一的字符串)
  3. 服务端发送token给客户端
  4. 客户端保存token,具体如何存储可以客户端自己决定
  5. 客户端发起请求时,将token放入http请求头
  6. 服务端校验token,就可以知道当前是哪个用户再进行操作
  7. 服务端返回业务处理的响应结果

示例代码

Token实体类,用于关联token和用户及维持token的有效时间。

@Component
public class Token {
    //用户名
    private String username;
    //token值
    private String token;
    //到期时间
    private Long expire;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public Long getExpire() {
        return expire;
    }

    public void setExpire(Long expire) {
        this.expire = expire;
    }
}

下面是Token的工具类,封装了token的产生函数

package com.ay.font_back.utils;

import com.ay.font_back.modal.Token;

import java.security.MessageDigest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/* *
 * Created by Ay on 2018/11/20
 */
public class TokenUtil {
    //加密的秘钥
    private static final char[] hexCode = "0123456789abcdef".toCharArray();

    //token有效时间             12小时后过期
    private final static int EXPIRE = 3600 * 12;

    //模拟数据库存储token
    //嫌数据库查询太慢 可以用redis储存
    public static Map<String,Token> map = new HashMap<>();
    /**
     * 调用接口
     * @param para
     * @return
     * @throws Exception
     */
    public static Token generateToken(String para) throws Exception {
        //产生token
        String token = generateToken(para,System.currentTimeMillis());
        //当前时间
        Date now = new Date();
        //到期时间
        Date expireTime = new Date(now.getTime()+EXPIRE);
        Token token1 = map.get(para);
        //未生成过token
        if(token1 == null){
            token1 = new Token();
            token1.setUsername(para);
            token1.setToken(token);
            token1.setExpire(expireTime.getTime());
            map.put(para,token1);
        }else {
            //更新token
            token1.setToken(token);
            token1.setExpire(expireTime.getTime());
        }

        return token1;
    }

    /**
     * 用户的每次访问后 更新过期时间
     * @param token
     */
    public static void updateExpireTime(Token token){
        token.setExpire(System.currentTimeMillis() + EXPIRE);
    }


    /**
     * 生成token
     * @param para
     * @param timestamp
     * @return
     * @throws Exception
     */
    public static String generateToken(String para,long timestamp) throws Exception {
        String text = String.format("%s,%d",para,timestamp);
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            byte[] bytes = md5.digest(text.getBytes("utf-8"));
            return toHexString(bytes);
        } catch (Exception e) {
            throw  new Exception("生成token失败");
        }
    }

    /**
     * 字节数组转为16进制
     * @param data
     * @return
     */
    public static String toHexString(byte[] data) {
        if(data == null) {
            return null;
        }
        StringBuilder r = new StringBuilder(data.length*2);
        for ( byte b : data) {
            r.append(hexCode[(b >> 4) & 0xF]);
            r.append(hexCode[(b & 0xF)]);
        }
        return r.toString();
    }
}

下面是controller,一个登录接口,一个请求数据接口,在请求时应统一对token进行验证,再完成业务逻辑后更新下token的过期时间,避免频繁地因为token过期重新认证。

@RestController
public class UserController {


    @RequestMapping("/user/login")
    public String login(@RequestParam("userName") String username,
                        @RequestParam("password") String password) throws Exception {
        //模拟查询数据库 校验用户名和密码
        if("Ay".equals(username) && "123456".equals(password)){
            Token token = TokenUtil.generateToken(username);
            return token.getToken();
        }
        return "账户密码错误";
    }

    @RequestMapping("/user/listData")
    public String listData(@RequestHeader("ay_token") String token){
        //判断token,可以通过 AOP,拦截器,过滤器统一进行权限校验
        Token token1 = null;
        for(Map.Entry<String,Token> entry :TokenUtil.map.entrySet()){
            //有token值存在
            if(entry.getValue().getToken().equals(token)){
                token1 = entry.getValue();
                break;
            }

        }
        if(token1 == null){
            return "无权访问";
        }
        //token 失效
        if(System.currentTimeMillis() > token1.getExpire()){
            return "请重新进行认证";
        }
        System.out.println("执行业务逻辑前token的到期时间:"+token1.getExpire());
        //查询用户信息,处理业务逻辑 。。。



        //aop 完成该操作  业务逻辑完成 更新token的过期时间
        TokenUtil.updateExpireTime(token1);
        System.out.println("执行业务逻辑后token的到期时间:"+token1.getExpire());
        return "这是要请求的数据....";
    }
}

前端部分代码,简单地模拟登录和访问具体接口

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>login</title>
    <script src="jquery.js"></script>
</head>
<body>
    <button onclick="onLogin()">登录</button><br>
    token:<p id="userToken"></p>


    <button onclick="getData()">获取数据</button><br>
    token:<p id="listData"></p>

    <script>
        function onLogin(){
            jQuery.ajax({
                method: "POST",
                url: "http://localhost:8080/user/login",
                data:
                    {
                        "userName":"Ay",
                        "password":"123456"
                    },
                success: function(data, textStatus, jqXHR)
                {
                    $("#userToken").html(data);
                },
                error: function( jqXHR, textStatus, errorThrown)
                {
                    trace( "error: " + errorThrown );
                }
            });
        }
        function getData(){
            jQuery.ajax({
                method: "POST",
                url: "http://localhost:8080/user/listData",
                headers:{
                  "ay_token":$("#userToken").html()
                },
                success: function(data, textStatus, jqXHR)
                {
                    $("#listData").html(data);
                },
                error: function( jqXHR, textStatus, errorThrown)
                {
                    trace( "error: " + errorThrown );
                }
            });
        }

    </script>
</body>
</html>

通过测试,程序运行成功,但仅仅是这样的话并不是真正的前后端分离。

静态服务器nginx

为实现真正的前后端分离,需将html/js/css这些静态文件放入nginx的html目录。nginx默认端口是80,通过启动nginx访问前端页面,前端页面再通过ajax访问tomcat上的java服务端程序,这时我们会发现ajax被拒绝访问。

前后端分离的后端架构mv 前后端分离 架构_前后端分离的后端架构mv_02


原因是浏览器有一个同源策略,即“协议+域名+端口”三者相同才能发起ajax请求,哪怕是IP地址相同也不行,上面这个情况是端口不同,前端在80,后端在8080,这样ajax访问就属于跨域访问了。

ajax跨域请求访问

ajax在跨域访问的时候会先发送一个options请求,询问服务端是否前端可以对其访问。上面的例子就是因为服务端不允许这个前端进行访问。
CORS解决方案
服务端设置Access-Control-Allow-Origin,如果是要带cookie请求,前后端都要设置,但这里使用的是token,所以只要设置后端即可。

// 允许跨域访问的域名:若有端口需写全(协议+域名+端口),若没有端口末尾不用加'/'
response.setHeader("Access-Control-Allow-Origin", "http://www.domain1.com");

spring boot的解决方案,在启动函数内加入这个bean,规定哪些网站可以进行访问。

/**
     * 支持跨域请求,允许localhost这个网站来请求user下的接口
     * @return
     */
    @Bean
    public WebMvcConfigurer coreWebMvcConfigurer(){
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                    registry.addMapping("/user/**").allowedOrigins("http://localhost");
            }
        };
    }