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

34次阅读

共计 10386 个字符,预计需要花费 26 分钟才能阅读完成。

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

正文完
 0