基于SpirngBoot的企业级后台管理框架Guns完整解析

小Hub领读:

guns这个项目相信不少人都知道,不知道大家有没完整读过呢,今天一块儿跟着小Hub来学习下哈。html

一共几个主要模块比较重要:前端

  • map + warpper模式
  • Api数据传输安全
  • 数据范围限定

视频讲解:https://www.bilibili.com/video/BV1P5411j7yA/git


简介

Guns基于SpringBoot 2,致力于作更简洁的后台管理系统。Guns项目代码简洁,注释丰富,上手容易,同时Guns包含许多基础模块(用户管理,角色管理,部门管理,字典管理等10个模块),能够直接做为一个后台管理系统的脚手架! web

官网:https://www.stylefeng.cn面试

视频讲解:https://www.bilibili.com/video/BV1P5411j7yA/spring

本次解读版本:tag-v4.2版本,由于5.0后的项目都是maven单项目,核心类都封装到jar中了,因此学习的话最好使用v4.2的最后一版本maven多模块项目学习。sql

图片

项目特色

  1. 基于SpringBoot,简化了大量项目配置和maven依赖,让您更专一于业务开发,独特的分包方式,代码多而不乱。
  2. 完善的日志记录体系,可记录登陆日志,业务操做日志(可记录操做前和操做后的数据),异常日志到数据库,经过@BussinessLog注解和LogObjectHolder.me().set()方法,业务操做日志可具体记录哪一个用户,执行了哪些业务,修改了哪些数据,而且日志记录为异步执行,详情请见@BussinessLog注解和LogObjectHolder,LogManager,LogAop类。
  3. 利用beetl模板引擎对前台页面进行封装和拆分,使臃肿的html代码变得简洁,更加易维护。
  4. 对经常使用js插件进行二次封装,使js代码变得简洁,更加易维护,具体请见webapp/static/js/common文件夹内js代码。
  5. 利用ehcache框架对常常调用的查询进行缓存,提高运行速度,具体请见ConstantFactory类中@Cacheable标记的方法。
  6. controller层采用map + warpper方式的返回结果,返回给前端更为灵活的数据,具体参见com.stylefeng.guns.modular.system.warpper包中具体类。
  7. 简单可用的代码生成体系,经过SimpleTemplateEngine可生成带有主页跳转和增删改查的通用控制器、html页面以及相关的js,还能够生成Service和Dao,而且这些生成项都为可选的,经过ContextConfig下的一些列xxxSwitch开关,可灵活控制生成模板代码,让您把时间放在真正的业务上。
  8. 控制器层统一的异常拦截机制,利用@ControllerAdvice统一对异常拦截,具体见com.stylefeng.guns.core.aop.GlobalExceptionHandler类。

技术选型

  • springboot
  • mybatis plus
  • shiro
  • beetl
  • ehcache
  • jwt

模块分析

学习一个项目就是学习项目的亮点地方,在分析guns的过程当中,有些地方值得咱们学习,下面咱们一一来分析:数据库

map + warpper模式

访问后台的用户列表时候,咱们一般须要去查询用户表,可是用户表里面有些外键,好比角色信息、部门信息等。所以有时候咱们查询列表时候通常在mapper中关联查询,而后获得记录。json

官网介绍:后端

map+warpper方式即为把controller层的返回结果使用BeanKit工具类把原有bean转化为Map的的形式(或者原有bean直接是map的形式),再用单独写的一个包装类再包装一次这个map,使里面的参数更加具体,更加有含义,下面举一个例子,例如,在返回给前台一个性别时,数据库查出来1是男2是女,假如直接返回给前台,那么前台显示的时候还须要增长一次判断,而且先后端分离开发时又增长了一次交流和文档的成本,可是采用warpper包装的形式,能够直接把返回结果包装一下,例如动态增长一个字段sexName直接返回给前台性别的中文名称便可。

guns项目中,做者说首创了一种map+warpper模式。咱们来看下是如何实现的。

看看下UserController的代码:

/**
 * 查询管理员列表
 */
@RequestMapping("/list")
@Permission
@ResponseBody
public Object list(@RequestParam(required = false) String name
    , @RequestParam(required = false) String beginTime
    , @RequestParam(required = false) String endTime
    , @RequestParam(required = false) Integer deptid) {
    if (ShiroKit.isAdmin()) {
        List<Map<String, Object>> users = userService.selectUsers(null, name, beginTime, endTime, deptid);
        return new UserWarpper(users).warp();
    } else {
        DataScope dataScope = new DataScope(ShiroKit.getDeptDataScope());
        List<Map<String, Object>> users = userService.selectUsers(dataScope, name, beginTime, endTime, deptid);
        return new UserWarpper(users).warp();
    }
}

userService.selectUsers中只是一个单表的查询操做,没有关联其余表,所以查询出来的结果中有些字段须要手动转换,好比sex、roleId等,所以做者定义了一个UserWarpper,用来转换这些特殊字段,好比sex存的0转成男,roleId查库以后转成角色名称等。

/**
 * 用户管理的包装类
 *
 * @author fengshuonan
 * @date 2017年2月13日 下午10:47:03
 */
public class UserWarpper extends BaseControllerWarpper {

    public UserWarpper(List<Map<String, Object>> list) {
        super(list);
    }

    @Override
    public void warpTheMap(Map<String, Object> map) {
        map.put("sexName", ConstantFactory.me().getSexName((Integer) map.get("sex")));
        map.put("roleName", ConstantFactory.me().getRoleName((String) map.get("roleid")));
        map.put("deptName", ConstantFactory.me().getDeptName((Integer) map.get("deptid")));
        map.put("statusName", ConstantFactory.me().getStatusName((Integer) map.get("status")));
    }

}

由于mybatis plus支持查询返回map的形式,因此只须要把map传进来,就能够转换成功,若是查询结果是一个实体的bean,那就先转成map,而后再用warpTheMap。其中BaseControllerWarpper也是一个关键抽象类,提供转换结果。

日志模块

日志记录采用aop(LogAop类)方式对全部包含@BussinessLog注解的方法进行aop切入,会记录下当前用户执行了哪些操做(即@BussinessLog value属性的内容)。

若是涉及到数据修改,会取当前http请求的全部requestParameters与LogObjectHolder类中缓存的Object对象的全部字段做比较(因此在编辑以前的获取详情接口中须要缓存被修改对象以前的字段信息),日志内容会异步存入数据库中(经过ScheduledThreadPoolExecutor类)。

  • 日志注解标识:com.stylefeng.guns.core.common.annotion.BussinessLog
  • 日志处理切面:com.stylefeng.guns.core.aop.LogAop
  • 日志记录字段字典:com.stylefeng.guns.core.common.constant.dictmap.base.AbstractDictMap
  • 任务式保存记录:LogManager.me().executeLog(TimerTask task)

jwt校验

在以前的课程中,咱们已经说过了不少次jwt的形式做为用户的token,在这项目中,jwt讲到了与Api的数据传输安全结合起来一块儿运用。首先咱们看下guns-rest项目,打开com.stylefeng.guns.rest.modular.auth.controller.AuthController,这个类是客户端调用登陆生成Jwt的地方。

@RestController
public class AuthController {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Resource(name = "simpleValidator")
    private IReqValidator reqValidator;

    /**
     * 请求生成jwt
     *
     * @param authRequest
     * @return
     */
    @RequestMapping(value = "${jwt.auth-path}")
    public ResponseEntity<?> createAuthenticationToken(AuthRequest authRequest) {

        boolean validate = reqValidator.validate(authRequest);

        if (validate) {
            final String randomKey = jwtTokenUtil.getRandomKey();
            final String token = jwtTokenUtil.generateToken(authRequest.getUserName(), randomKey);
            return ResponseEntity.ok(new AuthResponse(token, randomKey));
        } else {
            throw new GunsException(BizExceptionEnum.AUTH_REQUEST_ERROR);
        }
    }
}

来讲明一下上面的代码:

  • IReqValidator :帐号密码校验器
    • DbValidator
    • SimpleValidator
  • randomKey :随机生成的key,用于数据安全校验
  • token:生成保护用户id的jwt

因此app登陆调用这接口生成的值以下:

{
    "randomKey": "1jim2v",
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJyYW5kb21LZXkiOiIxamltMnYiLCJzdWIiOiJhZG1pbiIsImV4cCI6MTU2MjM5NjgwNCwiaWF0IjoxNTYxNzkyMDA0fQ.vr3HwhV_e8MrpNZY0rxbqs1cOzHIBdon4cQT-Gs9wvmv8UZEBbc4QNSMxTh_ulcVpkaw2uwZY4_8zJ7I2G-36Q"
}

图片

好了,客户端拿到token以后,每次请求须要在header中把token带上,而后服务过滤器校验

  • AuthFilter:校验jwt是否过时和是否正确
//验证token是否过时,包含了验证jwt是否正确
boolean flag = jwtTokenUtil.isTokenExpired(authToken);

ok,jwt的生成和校验逻辑都很简单,下面咱们来讲说接口传输安全是怎么作到的。

Api数据传输安全

上面咱们说到客户端登陆以后拿到了一个token和randomKey,token是用来校验用户身份的,那么这个randomKey是用来干吗的呢,实际上是用来作数据安全加密的。

当开启传输安全模式时候,客户端发送数据给服务器的时候会进行加密传输,具体的加密过程,guns中有一个com.stylefeng.guns.jwt.DecryptTest:

public static void main(String[] args) {

    String salt = "1jim2v";

    SimpleObject simpleObject = new SimpleObject();
    simpleObject.setUser("stylefeng");
    simpleObject.setAge(12);
    simpleObject.setName("ffff");
    simpleObject.setTips("code");

    String jsonString = JSON.toJSONString(simpleObject);
    String encode = new Base64SecurityAction().doAction(jsonString);
    String md5 = MD5Util.encrypt(encode + salt);

    BaseTransferEntity baseTransferEntity = new BaseTransferEntity();
    baseTransferEntity.setObject(encode);
    baseTransferEntity.setSign(md5);

    System.out.println(JSON.toJSONString(baseTransferEntity));
}

上面的过程就是把simpleObject 对象进行new Base64SecurityAction().doAction自定义加密(可自定义,项目只是简单Base64编码),而后加把加密后的值和salt进行Md5计算,得出来的md5就是签名,那么这个salt是哪里来的呢,其实这个salt的值就是randomKey的值。

  • DataSecurityAction:加密、解密的抽象类
  • Base64SecurityAction:其中一种实现,简单的Base64编码完成加密解密
  • 若是其余方式的直接实现DataSecurityAction便可

上面的main方法运行以后获得的值以下:

{"object":"eyJhZ2UiOjEyLCJuYW1lIjoiZmZmZiIsInRpcHMiOiJjb2RlIiwidXNlciI6InN0eWxlZmVuZyJ9",
"sign":"34bdd49a0838b1ef69cca928d71e885d"}

所以,客户端就是把这串数据传送到服务器: 图片

注意要填请求头:Authorization的值是:Bearer+空格+token,这个能够从AuthFilter中知道

图片

好了,上面发送给hello接口,那么咱们看下是如何接收和解密的,首先来看下接口:

@Controller
@RequestMapping("/hello")
public class ExampleController {

    @RequestMapping("")
    public ResponseEntity hello(@RequestBody SimpleObject simpleObject) {
        System.out.println(simpleObject.getUser());
        return ResponseEntity.ok("请求成功!");
    }
}

貌似没啥特殊的,参数SimpleObject应该是解析以后获得的值得,咱们都知道,咱们把参数写到控制器中时候,spring会自动帮咱们完成参数注入到实体bean的过程,咱们传过来的是一个加密的json,spring是帮不了咱们自动解析的,所以,这里咱们要作个手动转换json(解密)的过程,再完成注入;

先来分析一下spring的过程:在springboot项目里当咱们在控制器类上加上@RestController注解或者其内的方法上加入@ResponseBody注解后,默认会使用jackson插件来返回json数据。

所以咱们须要实现手动转成json与bean,只须要继承FastJsonHttpMessageConverter,重写read的过程。

guns项目中有WithSignMessageConverter 这样一个类:

/**
 * 带签名的http信息转化器
 *
 * @author fengshuonan
 * @date 2017-08-25 15:42
 */
public class WithSignMessageConverter extends FastJsonHttpMessageConverter {

    @Autowired
    JwtProperties jwtProperties;

    @Autowired
    JwtTokenUtil jwtTokenUtil;

    @Autowired
    DataSecurityAction dataSecurityAction;

    @Override
    public Object read(Type type, Class<?> contextClass
    , HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {

        InputStream in = inputMessage.getBody();
        Object o = JSON.parseObject(in, super.getFastJsonConfig().getCharset(), BaseTransferEntity.class, super.getFastJsonConfig().getFeatures());

        //先转化成原始的对象
        BaseTransferEntity baseTransferEntity = (BaseTransferEntity) o;

        //校验签名
        String token = HttpKit.getRequest().getHeader(jwtProperties.getHeader()).substring(7);
        String md5KeyFromToken = jwtTokenUtil.getMd5KeyFromToken(token);

        String object = baseTransferEntity.getObject();
        String json = dataSecurityAction.unlock(object);
        String encrypt = MD5Util.encrypt(object + md5KeyFromToken);

        if (encrypt.equals(baseTransferEntity.getSign())) {
            System.out.println("签名校验成功!");
        } else {
            System.out.println("签名校验失败,数据被改动过!");
            throw new GunsException(BizExceptionEnum.SIGN_ERROR);
        }

        //校验签名后再转化成应该的对象
        return JSON.parseObject(json, type);
    }
}

分析:首先从body中获取到json数据,而后从header中获取到jwt的token(为了拿到randomKey),而后再Md5计算,比较传过来的sign,一致表明数据是没被串改过的,而后dataSecurityAction.unlock解密获得原始的json数据,最后调用JSON.parseObject(json, type);把json转成SimpleObject,因此整过过程就是这样,perfect。

数据范围限定

关于数据范围限定的概念不少人不知道,咱们先来看下效果:

超级用户:admin登陆查看用户列表

图片

运营主管(运营部):test登陆查看用户列表

图片

从上面的两个登陆帐号中能够很直观看到,admin做为超级管理员,能够看到全部的数据,而test做为运营部的运营主管角色只能看到本身部门下的用户。

所以数据范围限定的意思就是根据用户的角色决定用户能查看的数据范围。

要完成这个功能有两个关键类:

  • DataScope:
public class DataScope {

    /**
     * 限制范围的字段名称
     */
    private String scopeName = "deptid";

    /**
     * 具体的数据范围
     */
    private List<Integer> deptIds;
    
    ...
}
  • DataScopeInterceptor
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class DataScopeInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) PluginUtils.realTarget(invocation.getTarget());
        MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);
        MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue("delegate.mappedStatement");

        if (!SqlCommandType.SELECT.equals(mappedStatement.getSqlCommandType())) {
            return invocation.proceed();
        }

        BoundSql boundSql = (BoundSql) metaStatementHandler.getValue("delegate.boundSql");
        String originalSql = boundSql.getSql();
        Object parameterObject = boundSql.getParameterObject();

        //查找参数中包含DataScope类型的参数
        DataScope dataScope = findDataScopeObject(parameterObject);

        if (dataScope == null) {
            return invocation.proceed();
        } else {
            String scopeName = dataScope.getScopeName();
            List<Integer> deptIds = dataScope.getDeptIds();
            String join = CollectionKit.join(deptIds, ",");
            originalSql = "select * from (" + originalSql + ") temp_data_scope where temp_data_scope." + scopeName + " in (" + join + ")";
            metaStatementHandler.setValue("delegate.boundSql.sql", originalSql);
            return invocation.proceed();
        }
    }
    ...
}

能够看出,其实就是一个mybatis的拦截器,拦截StatementHandler的prepare方法,而后在须要执行的sql外包装一层select * from(...)别名 where 别名.字段 in (范围)。 看起来逻辑仍是挺清晰的。回头看下用户的list代码,

DataScope dataScope = new DataScope(ShiroKit.getDeptDataScope());
List<Map<String, Object>> users = userService.selectUsers(dataScope, name, beginTime, endTime, deptid);

所以在须要数据范围限定的地方加上DataScope dataScope参数,拦截器会扫描参数中是否有 DataScope 类型,有的话就在sql外套上一层select * from,而后加上定义的字段限定范围。perfect~

结束语

好了,这里是MarkerHub,我是小Hub吕一明。就解读到这里,更多开源项目解读能够上 httts://markerhub.com


推荐阅读:

B站50K播放量,SpringBoot+Vue先后端分离完整入门教程!

分享一套SpringBoot开发博客系统源码,以及完整开发文档!速度保存!

Github上最值得学习的100个Java开源项目,涵盖各类技术栈!

2020年最新的常问企业面试题大全以及答案

相关文章
相关标签/搜索