如果你想开发一个应用(1-14)

之前在前端引用了axios,那么紧接着,后台要做如何的修改呢?直接返回html肯定是不对的,这时候,一个名为webapi的技术就出现了

webapi

webapi区别于普通的api,是指“使用http协议通过网络调用的API”即软件组织的外部接口。有时候也叫RESTful api,虽然他们实际上还是有一些区别的,但是基本上可以近似的理解他们是相同的,关于他们的定义,阮博写的还是非常的清晰。

SpringMVC中的webapi

在之前的程序中,我们返回的都是一个jsp模板的名字,然后框架自动去渲染这个jsp模板。但显然这个是不符合webapi的,那么我们想让他仅仅返回数据怎么办呢?这里介绍两个注解:ResponseBodyRestController,我们首先创建一个TestController控制器进行说明,他的代码很简单,首先:

@Controller
public class TestController {
    @ResponseBody
    @RequestMapping(value = "/test", method = {RequestMethod.GET})
    public Object test(){
        return "Hello world";
    }
}

然后在浏览器中直接访问http://localhost:8082/test,在返回页面查看源代码,只有Hello world

仿佛我们又回到了直接使用Servlet输出String的时代。

你可能注意到了,返回值是一个Object,那么我们返回一个对象试一下:

@ResponseBody
@RequestMapping(value = "/test", method = {RequestMethod.GET})
public Object test(){
    User user=new User();
    user.setId(1);
    user.setName("zhangsan");
    user.setPassword("123456");
    user.setCreateTime(new Date());
    return user;
}

查看一下返回信息:

{"name":"zhangsan","password":"123456","passwordSalt":null,"createTime":1514992830293,"id":1}

ok,比较完美,但是,如果一个控制器中所有的action均为webapi接口,这显然是一个很常见的事情,毕竟谁都不喜欢页面和json混合使用,那么这样写就是有些啰嗦了,这是就可以使用RestController使用它的效果就相当于所有的action都戴上了ResponseBody注解。我们使用这个注解对这个测试控制器进行一下修改:

@RestController
public class TestController {
    @RequestMapping(value = "/test", method = {RequestMethod.GET})
    public Object test(){
        User user=new User();
        user.setId(1);
        user.setName("zhangsan");
        user.setPassword("123456");
        user.setCreateTime(new Date());
        return user;
    }
}

运行,同样用刚刚土土的浏览器测试法测试一下,查看一下返回信息,依然是:

{"name":"zhangsan","password":"123456","passwordSalt":null,"createTime":1514992830293,"id":1}

PostMan

使用浏览器的测试方式虽然很方便,但是局限性也非常大,比如它只能测试Get方式,只能使用?传参的方式,无法对header赋值等等,这时候一个工具是非常必要的,有一款常用的工具是PostMan就非常的好用它是一个chrome的插件,所以暂时来说,安装它需要科学上网。

安装方式:

  1. 点击chrome最右边的三个点
  2. 在弹出菜单中选择更多工具
  3. 在弹出菜单中选择扩展程序(图1)
  4. 然后在搜索店内应用中搜索postman(图2)
  5. 接着一直下一步即可

图一

图二

如果安装完成后,多出一个类似应用程序的图标,因为经常使用我把他弄到了桌面的快捷方式,图标是这样的:


当看见这个火箭人的时候,就证明postman已经安装完成。

接下来双击我们测测试一下,在测试之前对代码进行一下修改:

@RestController
public class TestController {
    @RequestMapping(value = "/test", method = {RequestMethod.POST})
    public Object test(String username,String password){
        User user=new User();
        user.setId(1);
        user.setName(username);
        user.setPassword(password);
        user.setCreateTime(new Date());
        return user;
    }
}

然后运行,并如图在Postman内输入相应信息:

点击发送按钮,在body中可以看到已经自动格式化好的返回信息:

SpringMVC跨域

服务端的的配置完成之后,我们想到的就是客户端如何来调用它,回到vue的项目中,在views文件夹内,创建一个Test.vue文件,里边只写一个测试代码,访问服务端test服务,代码如下:

<style></style>
<template>
    <div>
        {{ txt }}
    </div>
</template>
<script>
    export default {
        data() {
            return {
                txt:'',
            }
        },
        created(){
            this.$http.post("/test",).then(res=>{
                    this.txt=res.data
                },res=>{
                    this.txt=res
                }
            )
        }
    }
</script>

代码虽然简单,但是已经可以看出一个vue组件的基本结构:

###style节点###

存放本组件所需的css,可以通过scoped来控制css类的作用域

###template节点###
一个组件的布局,即html模板,主要就用来开发的dom结构

###script###
vue组件最重要的部分,猜也能猜到用来存储整个页面的js逻辑部分。

这里可以看到js里比较重要的两个部分:

  1. data节点:此页面所使用的数据模型,vue与普通的jq之类的框架最大的区别就是数据驱动,这一点一定要牢牢记住
  2. create节点:页面布局创建时执行,这里让它在页面创建时执行ajax

运行,并在浏览器重输入http://localhost:8080/test/然后按f12,可以看到返回,哦 还有报错信息(警告不用理他,好多都是空格 tab 这类的问题):

这是一个跨域问题,在前后端分离开发的时候很常见的错误,在SpringMVC中解决这种问题主要有三种方法

  • 在Action上添加CrossOrigin注解
  • 在Controller上添加CrossOrigin注解

这里我选择了第三种方法,因为只有一个人开发,所以很犯懒,一股脑的把跨域权限全部打开,在WebConfig类内覆盖addCorsMappings方法:

public void addCorsMappings(CorsRegistry registry){
   registry.addMapping("/**");
}

重新运行一下tomcat服务器,并重新客户端测试:

可以完美访问。

参数

我们看到,他这时候还是在接收着两个参数,username和password,这里是null,这里添加两个输入框,用户输入用户名和密码,然后发送到服务端,服务端返回在页面底部显示,此功能修改后vue代码如下:

<template>
    <div>
        <table>
            <tr>
                <td>用户名</td><td><input type="text" v-model="username"></td>
            </tr>
            <tr><td>密码</td><td><input type="text" v-model="password"></td></tr>
            <tr><td colspan="2"><input type="button" @click="click" value="提交"></td></tr>
        </table>
        <br>
        <div>{{ message }}</div>
    </div>
</template>
<script>
    export default {
        data() {
            return {
                username:'',
                password:'',
                message:''
            }
        },
        methods:{
            click:function (event) {
                var data={
                    username:this.username,
                    password:this.password
                }
                this.$http.post("/test",data).then(res=>{
                    this.message=res.data.name+'__'+res.data.password
                },res=>{
                    this.txt=res
                }
            )
            }
        }
    }
</script>    

代码复杂了写,但依然很清晰,但是输入值并提交之后,最终的界面如下:

很明显,客户端传送的username和password服务端并没有接受到,这是为什么呢?我们f12看一下浏览器的http协议头的传值部分:

可以看到,提交方式为Payload方式,不同于一般formData。Payload是一种更加支持json数据的方式,这里的解决方式也有几种,比如修改配置强制为formData方式,用query方式等,这里我选择了一个不修改客户端,只修改服务端的方式,即读取requestBody,通过map方式接受参数:

顺便说一下,一般这种清醒下,我都选择修改服务端。

@RestController
public class TestController {
    @RequestMapping(value = "/test", method = {RequestMethod.POST})
    public Object test(@RequestBody Map map ){
        String username=map.get("username").toString();
        String password=map.get("password").toString();
        User user=new User();
        user.setId(1);
        user.setName(username);
        user.setPassword(password);
        user.setCreateTime(new Date());
        return user;
    }
}

这里的代码并不好,实际中这样的话客户端如果传少参数,传错参数都会报异常,正确的方式应该在服务端进行一下验证。

在此客户端服务器均重启测试一下:

终于正常了

token

webapi是基于http协议的,在之前我们了解到基于http协议就意味着它是无状态,短链接的,但是作为一个应用,必须知道当前使用的用户是哪一个。 也就是必须保持一个会话。

还记得之前jsp页面的会话是如何保持的么?通过一个jsessionid来进行自动处理的,这里我们也这样操作,服务端根据登录状态,保存一个令牌,有令牌进行处理,其中令牌保存方式现在采用最简单的房,仅仅保存在一个静态列表中,然后需要根据时间来决定令牌的生效级生效,所以,我们需要一个简单的令牌管理类:

public class TokenUtil {
    private static final int INTERVAL = 7;// token过期时间间隔 天
    private static final String SALT = "jtodos";// 加盐
    private static final int HOUR = 3;// 检查token过期线程执行时间 时
    private static Map<String, Token> tokenMap = new HashMap<String, Token>();
    private static TokenUtil tokenUtil = null;
    static ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    static {
        //开启监听
        listenTask();
    }
    public static TokenUtil getTokenUtil() {
        if (tokenUtil == null) {
            synInit();
        }
        return tokenUtil;
    }

    private static synchronized void synInit() {
        if (tokenUtil == null) {
            tokenUtil = new TokenUtil();
        }
    }
    private TokenUtil() {//禁止实例化
    }
    public static Map<String, Token> getTokenMap() {
        return tokenMap;
    }
    public static Token generateToken(String uniq, int id) {//创建token id为业务id
        String signature=MD5(System.currentTimeMillis() + SALT + uniq + id);
        Token token = new Token(signature, System.currentTimeMillis(),id);
        synchronized (tokenMap) {
            tokenMap.put(signature, token);
        }
        return token;
    }
    public static boolean removeToken(String signature) {//删除
        synchronized (tokenMap) {
            tokenMap.remove(signature);
        }
        return true;
    }
    public static long volidateToken(String signature) {  //检查token
        Token token = (Token) tokenMap.get(signature);
        if (token != null && token.getSignature().equals(signature)) {
            return token.getId();
        }
        return -1;
    }
    public final static String MD5(String s) {
        try {
            byte[] btInput = s.getBytes();
            // 获得MD5摘要算法的 MessageDigest 对象
            MessageDigest mdInst = MessageDigest.getInstance("MD5");
            // 使用指定的字节更新摘要
            mdInst.update(btInput);
            // 获得密文
            return byte2hex(mdInst.digest());
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
    private static String byte2hex(byte[] b) {
        StringBuilder sbDes = new StringBuilder();
        String tmp = null;
        for (int i = 0; i < b.length; i++) {
            tmp = (Integer.toHexString(b[i] & 0xFF));
            if (tmp.length() == 1) {
                sbDes.append("0");
            }
            sbDes.append(tmp);
        }
        return sbDes.toString();
    }
    public static void listenTask() {
        Calendar calendar = Calendar.getInstance();
        int year = calendar.get(Calendar.YEAR);
        int month = calendar.get(Calendar.MONTH);
        int day = calendar.get(Calendar.DAY_OF_MONTH);
        //定制每天的HOUR点,从明天开始
        calendar.set(year, month, day + 1, HOUR, 0, 0);
        // calendar.set(year, month, day, 17, 11, 40);
        Date date = calendar.getTime();
        scheduler.scheduleAtFixedRate(new ListenToken(), (date.getTime() - System.currentTimeMillis()) / 1000, 60 * 60 * 24, TimeUnit.SECONDS);
    }
    static class ListenToken implements Runnable {
        public ListenToken() {
            super();
        }
        public void run() {//监听Token列表
            try {
                synchronized (tokenMap) {
                    for (int i = 0; i < 5; i++) {
                        if (tokenMap != null && !tokenMap.isEmpty()) {
                            for (Map.Entry<String, Token> entry : tokenMap.entrySet()) {
                                Token token = (Token) entry.getValue();
                                int interval = (int) ((System.currentTimeMillis() - token.getTimestamp()) / 1000 / 60 / 60 / 24);
                                if (interval > INTERVAL) {
                                    tokenMap.remove(entry.getKey());
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

当然,还需要一个token对象:

public class Token {
    private String signature;
    private long timestamp;
    private long id;//userId
    public Token(String signature, long timestamp,long id) {
        if (signature == null)
            throw new IllegalArgumentException("signature can not be null");
        this.timestamp = timestamp;
        this.signature = signature;
        this.id=id;
    }
    public long getId(){
        return id;
    }
    public String getSignature() {
        return signature;
    }
    public long getTimestamp() {
        return timestamp;
    }
    //  重写哈希code timestamp 不予考虑, 因为就算 timestamp 不同也认为是相同的 token.
    public int hashCode() {
        return signature.hashCode();
    }

    public boolean equals(Object object) {
        if (object instanceof Token)
            return ((Token) object).signature.equals(this.signature);
        return false;
    }
    //调试用
    @Override
    public String toString() {
        return "Token [signature=" + signature + ", timestamp=" + timestamp + "]";
    }
}

这样,我们就可以再客户端保存一个token的值,来模拟jsessionid的角色,获取我们实际所需的对象,具体验证方式如下:

Long userId=TokenUtil.volidateToken("token");
if(userId==-1){
    throw  new RuntimeException("当前token已失效");
}else{
}

拦截器

但是,所有的东西就怕但是两个字,我们计划做的是一个日记的应用,既然是日记,我们就会希望只看到自己的日记(彩蛋除外),那么,几乎每个接口都需要验证token的,这样的工作即枯燥又繁杂,该如何解决呢?

还记得servlet中的filter么,在SpringMVC中提供了一个类似的,或者说加强版的东东,叫做拦截器,他比过滤器强大之处在于他可以访问ioc里的各个bean,这就提供了可以直接访问服务的能力,他的实现方式也很简单,需要继承一个HandlerInterceptor接口,然后在WebConfig中注册一下即可,我们设置一个用于权限控制的拦截器,具体代码如下:

public class SysPermissionInterceptor implements HandlerInterceptor {
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        String url = request.getRequestURI();
        //无权限页面直接过去 不用拦截
        if(url.contains("/denied")){
            return true;
        }
        String token= request.getHeader("token");
        //判断失败 直接跳到无权限页
        if (checkToken(token)&&checkUrl(url)) {
            request.getRequestDispatcher("/denied").forward(request,response);
            return false;
        }
        if(checkUrl(url)) {
            long id = TokenUtil.volidateToken(token);
            if (id == -1) {
                request.getRequestDispatcher("/denied").forward(request, response);
                return false;
            }
            //防止id重复 将id注入到请求里
            request.setAttribute("tokenId", id);
        }
        return  true;
    }
    //在执行handler返回modelAndView之前来执行
    //如果需要向页面提供一些公用 的数据或配置一些视图信息,使用此方法实现 从modelAndView入手
    public void postHandle(HttpServletRequest request,
                           HttpServletResponse response, Object handler,
                           ModelAndView modelAndView) throws Exception {
        //System.out.println("HandlerInterceptor1...postHandle");

    }
    //执行handler之后执行此方法
    //作系统 统一异常处理,进行方法执行性能监控,在preHandle中设置一个时间点,在afterCompletion设置一个时间,两个时间点的差就是执行时长
    //实现 系统 统一日志记录
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
       //System.out.println("HandlerInterceptor1...afterCompletion");
    }
    //帮助方法
    private boolean checkToken(String token){
        return null==token||"".equals(token);
    }
    //帮助方法
    private boolean checkUrl(String url){
        if(url.contains("/不许拦截的url,如login")) return false;
        return true;
    }
}

还需要一个denied的action,这个就很简单了,直接返回没有权限即可。

最后,还需要在WebConfig进行一下注册:

public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new SysPermissionInterceptor());
}

这样,就对任何action的请求都会进行token的验证

格式约定

回到denied,既然是双方独立开发,那么就要约定一个固定的json格式,否则任何一方的修改都可能会导致客户端的数据解析失败,这里以denied返回的权限失败为例,定义一个基准的json格式:

{
    "msg": "没有权限",
    "code": 200,
    "data": ""
}

这里暂时约定,code统一为200,以配合http的状态码,如果以后修改为直接使用http状态码也方便,然后,约定msg返回错误信息,数据放到data中,即判断如果msg==””,从data节点内读取返回的数据,否则输出异常信息。

同样的,如果每个action都进行json的维护,那工作量同样是即枯燥又易错的,最简单的方法当时在拦截器的afterCompletion方法中进行配置,但为了提高灵活度,我决定做一个父类,在父类的方法内包装json对象,然后子类调用,父类的代码如下:

public abstract class BaseController {
    public Map<String,Object> result(){
        return result(200,"","");
    }
    public Map<String,Object> result(Object data){
        return result(200,"",data);
    }
    public Map<String,Object> result(int code,Object data){
        return result(code,"",data);
    }
    public Map<String,Object> result(int code,String msg,Object data){
        Map<String,Object> resutl=new HashMap<String,Object>();
        resutl.put("code",code);
        resutl.put("msg",msg);
        resutl.put("data",data);
        return resutl;
    }
}

这是一个抽象类,里边有若干个result方法的重载。

所有的contrller都继承这个类,然后返回result方法的返回值:

@ResponseBody
@RequestMapping(value = "/denied",method = {RequestMethod.POST,RequestMethod.GET})
public Object denied(){
    return result(200,"没有权限","");
}

这样,返回的信息就是一个基准的json信息了,客户端就可以根据这个格式进行解析。

现在,客户端与服务端链接的部分框架已经基本完成,并定义了双方共同约定的json格式,接下来就可以针对具体业务进行双方的开发了。

谢谢观看