開源一套極簡的前後端分離專案腳手架
前言
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專案完成部署
後記
一套極簡的前後端分離專案腳手架就暫時記錄到這,後續再進行補充
程式碼開源
注:admin後端資料庫檔案在admin後端專案的resources/sql目錄下面
程式碼已經開源、託管到我的GitHub、碼雲:
GitHub:https://github.com/huanzi-qch/fast-scaffold
碼雲:https://gitee.com/huanzi-qch/fast-scaffold