開源一套極簡的前後端分離專案腳手架

qch發表於2020-11-06

  前言

  Fast Scaffold是一套極簡的前後端分離專案腳手架,包含一個portal前端、一個admin後端,可用於快速的搭建前後端分離專案進行二次開發

 

  技術棧

  portal前端:vue + element-ui + avue,使用typescript語法編碼

  admin後端:springboot + mybatis-plus + mysql,採用jwt進行身份認證

 

  專案結構

  

 

 

 

 

  portal前端

  前端專案,使用的是我們:Vue專案入門例項,在此基礎上做了一下跳轉

 

  引入avue

  avue,基於element-ui開發的一個很多騷操作的前端框架,我們也在test測試模組中的Admin頁面中進行了簡單測試

  官網:https://avuejs.com/

 

  router配置

  router路由配置,新增test模組選單路由,beforeEach中判斷無令牌,跳轉登入頁面

router.beforeEach(async(to, from, next) => {
    console.log("跳轉開始,目標:"+to.path);
    document.title = `${to.meta.title}`;

    //無令牌,跳轉登入頁面
    if (to.name !== 'Login' && !TokenUtil.getToken()){
        console.log("無令牌,跳轉登入頁面");
        next({ name: 'Login' });
    }

    //跳轉頁面
    next();
});

 

  store配置

  store配置,新增user屬性,getters提供getUser方法,以及mutations、actions的setUser方法

import Vue from 'vue'
import Vuex from 'vuex'
import User from "@/vo/user";
import CommonUtil from "@/utils/commonUtil";
import {Object} from  "@/utils/commonUtil"
import AxiosUtil from "@/utils/axiosUtil";
import TokenUtil from "@/utils/tokenUtil";
import SessionStorageUtil from "@/utils/sessionStorageUtil";

Vue.use(Vuex);

/*
  約定,元件不允許直接變更屬於 store 例項的 state,而應執行 action 來分發 (dispatch) 事件通知 store 去改變
 */
export default new Vuex.Store({
  state: {
    user:User,
  },
  getters:{
    getUser: state => {
      return state.user;
    }
  },
  mutations: {
    SET_USER: (state, user) => {
      state.user = user;
    }
  },
  actions: {
    async setUser({commit}){
      let thid = this;
      console.log("呼叫getUserByToken介面獲取登入使用者!");
      AxiosUtil.post(CommonUtil.getAdminUrl()+"/getUserByToken",{token:TokenUtil.getToken()},function (result) {
        let data = result.data as Object;
        commit('SET_USER', new User(data.id,data.username));

        //設定到sessionStorage
        SessionStorageUtil.setItem("loginUser",thid.getters.getUser);
      });
    }
  },
  modules: {
  }
})

 

  工具類封裝

 

  axiosUtil.ts

  設定全域性withCredentials,timeout

  設定request攔截,在請求頭中設定token令牌

  設定response攔截,設定了統一響應異常訊息提示以及令牌無效時跳轉登入頁面

  封裝了post、get等靜態方法,方便呼叫

 

  commonUtil.ts

  封裝了一下常用、通用方法,比如獲取後端服務地址、獲取登入使用者等

 

  sessionStorageUtil.ts

  封裝sessionStorage會話級快取,方便設定快取

 

  tokenUtil.ts

  封裝token令牌工具類,方便設定token令牌到cookie

 

  admin後端

  後端專案,使用的是我們的:SpringBoot系列——MyBatis-Plus整合封裝,在此基礎上進行了調整

  只保留tb_user表模組,其他表以及程式碼模組都不需要,密碼改成MD5加密儲存

 

  配置檔案

server:
  port: 10086
spring:
  application:
    name: admin
  datasource: #資料庫相關
    url: jdbc:mysql://localhost:3306/test?serverTimezone=GMT%2B8&characterEncoding=utf-8
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  mvc:
    format:
      date: yyyy-MM-dd HH:mm:ss

  jackson:
    date-format: yyyy-MM-dd HH:mm:ss #jackson對響應回去的日期引數進行格式化
    time-zone: GMT+8

portal:
  url: http://172.16.35.52:10010 #前端地址(用於跨域配置)

token:
  secret: huanzi-qch #token加密私鑰(很重要,注意保密)
  expire:
    time: 86400000 #token有效時長,單位毫秒 24*60*60*1000

 

  cors安全跨域

  建立MyConfiguration,開啟cors安全跨域,詳情可看回我們之前的部落格:SpringBoot系列——CORS(跨源資源共享)

@Configuration
public class MyConfiguration {

    @Value("${portal.url}")
    private String portalUrl;

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                        .allowedOrigins(portalUrl)
                        .allowedMethods("*")
                        .allowedHeaders("*")
                        .allowCredentials(true).maxAge(3600);
            }
        };
    }
}

 

  jwt身份認證

  maven引入jwt依賴

        <!-- JWT -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.5.0</version>
        </dependency>

 

  JwtUtil工具類,封裝生成token,校驗token,以及根據token獲取登入使用者

/**
 * JWT工具類
 */
@Component
public class JwtUtil {

    /**
     * 過期時間,毫秒
     */
    private static long TOKEN_EXPIRE_TIME;
    @Value("${token.expire.time}")
    public void setExpireTime(long expireTime) {
        JwtUtil.TOKEN_EXPIRE_TIME = expireTime;
    }

    /**
     * token私鑰
     */
    private static String TOKEN_SECRET;
    @Value("${token.secret}")
    public void setSecret(String secret) {
        JwtUtil.TOKEN_SECRET = secret;
    }

    /**
     * 生成簽名
     */
    public static String sign(String userId){
        //過期時間
        Date date = new Date(System.currentTimeMillis() + TOKEN_EXPIRE_TIME);
        //私鑰及加密演算法
        Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
        //設定頭資訊
        HashMap<String, Object> header = new HashMap<>(2);
        header.put("typ", "JWT");
        header.put("alg", "HS256");
        //附帶userID生成簽名
        return JWT.create().withHeader(header).withClaim("userId",userId).withExpiresAt(date).sign(algorithm);
    }

    /**
     * 驗證簽名
     */
    public static boolean verity(String token){
        //令牌為空
        if(StringUtils.isEmpty(token)){
            return false;
        }

        try {
            Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
            JWTVerifier verifier = JWT.require(algorithm).build();

            //是否能解密
            DecodedJWT jwt = verifier.verify(token);

            //校驗過期時間
            if(new Date().after(jwt.getExpiresAt())){
                return false;
            }

            return true;
        } catch (IllegalArgumentException | JWTVerificationException e) {
            ErrorUtil.errorInfoToString(e);
        }
        return false;
    }

    /**
     * 根據token獲取使用者id
     */
    public static String getUserIdByToken(String token){
        try {
            Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
            JWTVerifier verifier = JWT.require(algorithm).build();
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getClaim("userId").asString();
        } catch (IllegalArgumentException | JWTVerificationException e) {
            ErrorUtil.errorInfoToString(e);
        }
        return null;
    }
}

 

  登入攔截器

  LoginFilter登入攔截器,不攔截登入請求、跨域預檢請求,其他請求全部攔截校驗是否有令牌

  PS:我們已經配置了全域性安全跨域,但在攔截器中,PrintWriter.print回去的response,要手動新增一下響應頭標記允許對方跨域

//標記當前請求對方允許跨域訪問
response.setHeader("Access-Control-Allow-Credentials","true");
response.setHeader("Access-Control-Allow-Headers","content-type, token");
response.setHeader("Access-Control-Allow-Methods","*");
response.setHeader("Access-Control-Allow-Origin",portalUrl);

 

/**
 * 登入攔截器
 */
@Component
public class LoginFilter implements Filter {

    @Value("${server.servlet.context-path:}")
    private String contextPath;

    @Value("${portal.url}")
    private String portalUrl;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        String method = request.getMethod();

        //不攔截登入請求、跨域預檢請求,其他請求全部攔截校驗是否有令牌
        if (!"/login".equals(request.getRequestURI().replaceFirst(contextPath,"")) && !"options".equals(method.toLowerCase())) {
            String token = request.getHeader("token");

            //驗證簽名
            if(!JwtUtil.verity(token)){
                String dataString = "{\"status\":401,\"message\":\"無效token令牌,訪問失敗,請重新登入系統!\"}";

                //清除cookie
                Cookie cookie = new Cookie("PORTAL_TOKEN", null);
                cookie.setPath("/");
                cookie.setMaxAge(0);
                response.addCookie(cookie);

                //轉json字串並轉成Object物件,設定到Result中並賦值給返回值,記得表明當前頁面可以跨域訪問
                response.setHeader("Access-Control-Allow-Credentials","true");
                response.setHeader("Access-Control-Allow-Headers","content-type, token");
                response.setHeader("Access-Control-Allow-Methods","*");
                response.setHeader("Access-Control-Allow-Origin",portalUrl);

                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json; charset=utf-8");
                PrintWriter out = response.getWriter();
                out.print(dataString);
                out.flush();
                out.close();

                return;
            }
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }
}

 

  簡單控制器

  IndexController控制器,提供三個post方法:login登入,logout登出,getUserByToken通過token令牌獲取登入使用者

@RestController
@RequestMapping("/")
@Slf4j
public class IndexController {

    @Autowired
    private TbUserService tbUserService;

    /**
     * 登入
     */
    @PostMapping("login")
    public Result<String> login(@RequestBody TbUserVo entityVo){
        //只關注使用者名稱、密碼
        if(StringUtils.isEmpty(entityVo.getUsername()) || StringUtils.isEmpty(entityVo.getPassword())){
            return Result.build(400,"賬號或密碼不能為空......","");
        }
        TbUserVo tbUserVo = new TbUserVo();
        tbUserVo.setUsername(entityVo.getUsername());
        //密碼MD5加密後密文儲存,匹配時先MD5加密後匹配
        tbUserVo.setPassword(MD5Util.getMD5(entityVo.getPassword()));
        Result<List<TbUserVo>> listResult = tbUserService.list(tbUserVo);
        if(Result.OK.equals(listResult.getStatus()) && listResult.getData().size() > 0){
            TbUserVo userVo = listResult.getData().get(0);

            //token
            String token = JwtUtil.sign(userVo.getId()+"");

            return Result.build(Result.OK,"登入成功!",token);
        }
        return Result.build(400,"賬號或密碼錯誤...","");
    }

    /**
     * 登出
     */
    @PostMapping("logout")
    public Result<String> logout(HttpServletResponse response){
        //清除cookie
        Cookie cookie = new Cookie("PORTAL_TOKEN", null);
        cookie.setPath("/");
        cookie.setMaxAge(0);
        response.addCookie(cookie);
        return Result.build(Result.OK,"此路是我開,此樹是我栽,要從此路過,留下token令牌!","");
    }

    /**
     * 通過token令牌獲取登入使用者
     */
    @PostMapping("getUserByToken")
    public Result<TbUserVo> getUserByToken(@RequestBody TbUserVo entityVo){
        String userId = JwtUtil.getUserIdByToken(entityVo.getToken());
        Result<TbUserVo> result = tbUserService.get(userId);
        result.getData().setPassword(null);
        return userId == null ? Result.build(500,"操作失敗!",new TbUserVo()) : result;
    }
}

 

  效果演示

 

  登入

  這是一個極簡登入頁面、登入功能,沒用令牌,路由會攔截跳到登入頁面

  登入成功後儲存token令牌到cookie中,並獲取登入使用者資訊,儲存到Store中

  為了解決重新整理頁面Store資料丟失,同時要儲存一份資料到sessionStorage快取,在讀取Store無資料時,先讀取快取,如果存在,再設定回Store中

  登出成功後置空Store、sessionStorage

 

  首頁

  極簡的專案首頁,路徑/,一般作為專案主頁,現在頁面就是一個簡單的歡迎頁面,包括了幾個router-link路由以及登出按鈕

  

 

  test測試

  整合了vue資料繫結等簡單測試

  info測試

  獲取當前活躍配置環境分支,讀取配置檔案資訊等簡單測試

  admin測試

  element-ui配合上avue,可以快速搭建admin後臺管理頁面以及功能

 

  打包部署

  portal前端

  已經配置好了package.json檔案

  "scripts": {
    "dev": "vue-cli-service serve --mode dev",
    "test": "vue-cli-service test --mode test",
    "build": "vue-cli-service build  --mode prod"
  },

 

  同時,vue.config.js中配置了生成路徑

    publicPath: './',
    outputDir: 'dist',
    assetsDir: 'static',

  

  執行build命令,就會在package.json的同級目錄下面,建立dist資料夾,生成的檔案就在裡面

  把生成的檔案放到Tomcat容器或者其他容器中,執行容器,前端portal專案完成部署

  

  admin後端

  pom檔案已經設定了打包配置

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <finalName>${project.artifactId}</finalName>
        <outputDirectory>package</outputDirectory>
    </configuration>
</plugin>

  maven直接執行package命令,就會在與pom檔案同級目錄下面建立package資料夾,生成的jar包就在裡面

  使用java命令:java -jar admin.jar,執行jar包,後端admin專案完成部署

 

  後記

  一套極簡的前後端分離專案腳手架就暫時記錄到這,後續再進行補充 

 

  程式碼開源

   注:資料庫檔案在resources/sql目錄下面

 

  程式碼已經開源、託管到我的GitHub、碼雲:

  GitHub:https://github.com/huanzi-qch/fast-scaffold

  碼雲:https://gitee.com/huanzi-qch/fast-scaffold

相關文章