基於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年最新的常問企業面試題大全以及答案

相關文章
相關標籤/搜索