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

小Hub领读:

guns这个我的项目置信很多人都晓得,不晓得你们有没残缺读过呢,明天一起跟着小Hub来学习下哈。

一共几个次要模块比拟重要:

  • map + warpper模式
  • Api数据传输平安
  • 数据范畴限定

简介

Guns基于SpringBoot 2,致力于做更简洁的后盾管理系统。Guns我的项目代码简洁,正文丰盛,上手容易,同时Guns蕴含许多根底模块(用户治理,角色治理,部门治理,字典治理等10个模块),能够间接作为一个后盾管理系统的脚手架! 

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

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

本次解读版本:tag-v4.2版本,因为5.0后的我的项目都是maven单我的项目,外围类都封装到jar中了,所以学习的话最好应用v4.2的最初一版本maven多模块我的项目学习。

  • https://gitee.com/stylefeng/guns/tree/v4.2/

我的项目特点

  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中关联查问,而后失去记录。

官网介绍:

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年最新的常问企业面试题大全以及答案

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理