從接觸springboot開始,便深深的被它的簡潔性深深的折服了,精簡的配置,方便的集成,使我再也不想用傳統的ssm框架來搭建項目,一大堆的配置文件,維護起來很不方便,集成的時候也要費力不少。從第一次使用springboot開始,一個簡單的main方法,甚至一個配置文件也不需要(當然我是指的沒有任何數據交互,沒有任何組件集成的情況),就可以把一個web項目啟動起來,下面總結一下自從使用springboot依賴,慢慢完善的自己的一個web系統的架構,肯定不是最好的,但平時自己用着很舒服。
1. 配置信息放到數據庫里邊
個人比較不喜歡配置文件,因此有一個原則,配置文件能不用就不用,配置信息能少些就少些,配置內容能用代碼寫堅決不用xml,因此我第一個想到的就是,能不能把springboot的配置信息寫到數據庫里,在springboot啟動的時候自動去加載,而在application.properties里邊只寫一個數據源。最終找到了方法:
注意圖中箭頭指向的兩行,構造了一個properties對象,然后將這個對象放到了springboot的啟動對象application中,properties是一個類似map的key-value容器,springboot可以將其中的東西當做成原來application.properties中的內容一樣,因此在properties對象的內容也就相當於寫在了application.properties文件中。知道了這個之后就簡單了,我們將原本需要寫在application.properties中的所有配置信息寫在數據庫中,在springboot啟動的時候從數據庫中讀取出來放到properties對象中,然后再將這個對象set到application中即可。上圖中PropertyConfig.loadProperties()方法就是進行了這樣的操作,代碼如下:

1 public class PropertyConfig { 2 3 /** 4 * 生成Properties對象 5 */ 6 public static Properties loadProperties() { 7 Properties properties = new Properties(); 8 loadPropertiesFromDb(properties); 9 return properties; 10 } 11 12 /** 13 * 從數據庫中加載配置信息 14 */ 15 private static void loadPropertiesFromDb(Properties properties) { 16 InputStream in = PropertyConfig.class.getClassLoader().getResourceAsStream("application.properties"); 17 try { 18 properties.load(in); 19 } catch (Exception e) { 20 e.printStackTrace(); 21 } 22 String profile = properties.getProperty("profile"); 23 String driverClassName = properties.getProperty("spring.datasource.driver-class-name"); 24 String url = properties.getProperty("spring.datasource.url"); 25 String userName = properties.getProperty("spring.datasource.username"); 26 String password = properties.getProperty("spring.datasource.password"); 27 28 Connection conn = null; 29 PreparedStatement pstmt = null; 30 ResultSet rs = null; 31 try { 32 Class.forName(driverClassName); 33 String tableName = "t_config_dev"; 34 if ("pro".equals(profile)) { 35 tableName = "t_config_pro"; 36 } 37 String sql = "select * from " + tableName; 38 conn = DriverManager.getConnection(url, userName, password); 39 pstmt = conn.prepareStatement(sql); 40 rs = pstmt.executeQuery(); 41 while (rs.next()) { 42 String key = rs.getString("key"); 43 String value = rs.getString("value"); 44 properties.put(key, value); 45 } 46 } catch (Exception e) { 47 e.printStackTrace(); 48 } finally { 49 try { 50 if (conn != null) { 51 conn.close(); 52 } 53 if (pstmt != null) { 54 pstmt.close(); 55 } 56 if (rs != null) { 57 rs.close(); 58 } 59 } catch (Exception e) { 60 e.printStackTrace(); 61 } 62 } 63 } 64 65 }
代碼中,首先使用古老的jdbc技術,讀取數據庫t_config表,將表中的key-value加載到properties中,代碼中profile是為了區分開發環境和生產環境,以便於確定從那張表中加載配置文件,數據庫中的配置信息如下:
這樣以后,application.properties中就不用再寫很多的配置信息,而且,如果將這些配置信息放到數據庫中之后,如果起多個應用可是公用這一張表,這樣也可以做到配置信息的公用的效果,這樣修改以后,配置文件中就只有數據源的信息了:
profile代表使用哪個環境,代碼中可以根據這個信息來從開發表中加載配置信息還是從生產表中加載配置信息。
2. 統一返回結果
一般web項目中,大多數都是接口,以返回json數據為主,因此統一一個返回格式很必要。在本示例中,建了一個BaseController,所有的Controller都需要繼承這個類,在這個BaseController中定義了成功的返回和失敗的返回,在其他業務的Controller中,返回的時候,只需要return super.success(xxx)或者return super.fail(xxx, xxx)即可,例:
說到這里,返回給前台的狀態碼,建議也是封裝成一個枚舉類型,不建議直接返回200、400之類的,不方便維護也不方便查詢。那么BaseController里做了什么呢?如下:
定義一個ResultInfo類,該類只有兩個屬性,一個是Integer類型的狀態碼,一個是泛型,用於成功時返回給前台的數據,和失敗時返回給前台的提示信息。
3. 統一異常捕獲
在上一步中的Controller代碼中看到拋出了一個自定義的異常,在Controller中,屬於最外層的代碼了,這個時候如果有異常就不能直接拋出去了,這里再拋出去就沒有人處理了,服務器只能返回給前台一個錯誤,用戶體驗不好。因此,建議所有的Controller代碼都用try-catch包裹,捕獲到異常后統一進行處理,然后再給前台一個合理的提示信息。在上一步中拋出了一個自定義異常:
throw new MyException(ResultEnum.DELETE_ERROR.getCode(), "刪除員工出錯,請聯系網站管理人員。", e);
該自定義異常有三個屬性,分別是異常狀態碼,異常提示信息,以及捕獲到的異常對象,接下來定義一個全局的異常捕獲,統一對異常進行處理:
1 @Slf4j 2 @ResponseBody 3 @ControllerAdvice 4 public class GlobalExceptionHandle { 5 6 /** 7 * 處理捕獲的異常 8 */ 9 @ExceptionHandler(value = Exception.class) 10 public Object handleException(Exception e, HttpServletRequest request, HttpServletResponse resp) throws IOException { 11 log.error(AppConst.ERROR_LOG_PREFIX + "請求地址:" + request.getRequestURL().toString()); 12 log.error(AppConst.ERROR_LOG_PREFIX + "請求方法:" + request.getMethod()); 13 log.error(AppConst.ERROR_LOG_PREFIX + "請求者IP:" + request.getRemoteAddr()); 14 log.error(AppConst.ERROR_LOG_PREFIX + "請求參數:" + ParametersUtils.getParameters(request)); 15 if (e instanceof MyException) { 16 MyException myException = (MyException) e; 17 log.error(AppConst.ERROR_LOG_PREFIX + myException.getMsg(), myException.getE()); 18 if (myException.getCode().equals(ResultEnum.SEARCH_PAGE_ERROR.getCode())) { 19 JSONObject result = new JSONObject(); 20 result.put("code", myException.getCode()); 21 result.put("msg", myException.getMsg()); 22 return result; 23 } else if (myException.getCode().equals(ResultEnum.ERROR_PAGE.getCode())) { 24 resp.sendRedirect("/err"); 25 return ""; 26 } else { 27 return new ResultInfo<>(myException.getCode(), myException.getMsg()); 28 } 29 } else if (e instanceof UnauthorizedException) { 30 resp.sendRedirect("/noauth"); 31 return ""; 32 } else { 33 log.error(AppConst.ERROR_LOG_PREFIX + "錯誤信息:", e); 34 } 35 resp.sendRedirect("/err"); 36 return ""; 37 } 38 39 }
統一捕獲異常之后,可以進行相應的處理,我這里沒有進行特殊的處理,只是進行了一下區分,獲取數據的接口拋出的異常,前台肯定是使用的ajax請求,因此返回前台一個json格式的信息,提示出錯誤內容。如果是跳轉頁面拋出的異常,類似404之類的,直接跳轉到自定義的404頁面。補充一點,springboot項目默認是有/error路由的,返回的就是error頁面,所以,如果你在你的項目中定義一個error.html的頁面,如果報404錯誤,會自動跳轉到該頁面。
補充,統一異常處理類中使用了一個注解@Slf4j,該注解是lombok包中的,項目中加入了該依賴后,再也不用寫繁瑣的get、set等代碼,當然類似的像上邊的聲明log對象的代碼也不用寫了:
4. 日志配置文件區分環境
本示例使用的是logback日志框架。需要在resources目錄中添加logback.xml配置文件,這是一個比較頭疼的地方,我本來想一個配置文件也沒有的,奈何我也不知道怎么將這個日志的配置文件放到數據庫中,所以暫時先這么着了,好在幾乎沒有需要改動它的時候。
我在項目中添加了兩個日志的配置文件,分別是logback-dev.xml和logback-pro.xml可以根據不同的環境決定使用哪個配置文件,在數據庫配置表中(相當於寫在了application.properties中)添加一條配置logging.config=classpath:logback-dev.xml來區分使用哪個文件作為日志的配置文件,配置文件內容如下:

1 <?xml version="1.0" encoding="UTF-8"?> 2 <configuration> 3 4 <property name="LOG_HOME" value="/Users/oven/log/demo"/> 5 <!-- INFO日志定義 --> 6 <appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender"> 7 <File>${LOG_HOME}/demo.info.log</File> 8 <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> 9 <FileNamePattern>${LOG_HOME}/demo.info.%d{yyyy-MM-dd}.log</FileNamePattern> 10 <maxHistory>180</maxHistory> 11 </rollingPolicy> 12 <encoder> 13 <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</Pattern> 14 <charset>UTF-8</charset> 15 </encoder> 16 </appender> 17 18 <!-- ERROR日志定義 --> 19 <appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender"> 20 <File>${LOG_HOME}/demo.error.log</File> 21 <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> 22 <FileNamePattern>${LOG_HOME}/demo.error.%d{yyyy-MM-dd}.log</FileNamePattern> 23 <maxHistory>180</maxHistory> 24 </rollingPolicy> 25 <encoder> 26 <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</Pattern> 27 <charset>UTF-8</charset> 28 </encoder> 29 </appender> 30 31 <!-- DEBUG日志定義 --> 32 <appender name="DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender"> 33 <File>${LOG_HOME}/demo.debug.log</File> 34 <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> 35 <FileNamePattern>${LOG_HOME}/demo.debug.%d{yyyy-MM-dd}.log</FileNamePattern> 36 <maxHistory>180</maxHistory> 37 </rollingPolicy> 38 <encoder> 39 <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</Pattern> 40 <charset>UTF-8</charset> 41 </encoder> 42 </appender> 43 44 <!-- 定義控制台日志信息 --> 45 <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> 46 <encoder> 47 <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern> 48 </encoder> 49 </appender> 50 51 <root level="INFO"> 52 <appender-ref ref="STDOUT"/> 53 </root> 54 <logger name="com.oven.controller" level="ERROR"> 55 <appender-ref ref="ERROR"/> 56 </logger> 57 <logger name="com.oven.exception" level="ERROR"> 58 <appender-ref ref="ERROR"/> 59 </logger> 60 <logger name="com.oven.mapper" level="DEBUG"> 61 <appender-ref ref="DEBUG"/> 62 </logger> 63 <logger name="com.oven.aop" level="INFO"> 64 <appender-ref ref="INFO"/> 65 </logger> 66 67 </configuration>
在配置文件中,定義了三個級別的日志,info、debug和error分別輸出到三個文件中,便於查看。在生成日志文件的時候,進行了按照日志進行拆分的配置,每一個級別的日志每一天都會重新生成一個,根據日期進行命名,超過180天的日志將自動會刪除。當然你還可以按照日志大小進行拆分,我這里沒有進行這項的配置。
5. 全局接口請求記錄
進行全局的接口請求記錄,可以記錄接口的別調用情況,然后進行一些統計和分析,在本示例中,只是將全局的接口調用情況記錄到了info日志中,沒有進行相應的分析操作:
1 @Slf4j 2 @Aspect 3 @Component 4 public class WebLogAspect { 5 6 @Pointcut("execution(public * com.oven.controller.*.*(..))") 7 public void webLog() { 8 } 9 10 @Before("webLog()") 11 public void doBefore() { 12 // 獲取請求 13 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); 14 @SuppressWarnings("ConstantConditions") HttpServletRequest request = attributes.getRequest(); 15 // 記錄請求內容 16 log.info(AppConst.INFO_LOG_PREFIX + "請求地址:" + request.getRequestURL().toString()); 17 log.info(AppConst.INFO_LOG_PREFIX + "請求方法:" + request.getMethod()); 18 log.info(AppConst.INFO_LOG_PREFIX + "請求者IP:" + request.getRemoteAddr()); 19 log.info(AppConst.INFO_LOG_PREFIX + "請求參數:" + ParametersUtils.getParameters(request)); 20 } 21 22 @AfterReturning(returning = "ret", pointcut = "webLog()") 23 public void doAfterReturning(Object ret) { 24 // 請求返回的內容 25 if (ret instanceof ResultInfo) { 26 log.info(AppConst.INFO_LOG_PREFIX + "返回結果:" + ((ResultInfo) ret).getCode().toString()); 27 } 28 } 29 30 }
6. 集成shiro實現權限校驗
集成shirl,輕松的實現了權限的管理,如果對shiro不熟悉朋友,還需要先把shiro入門一下才好,shiro的集成一般都需要自定義一個realm,來進行身份認證和授權,因此先來一個自定義realm:

1 public class MyShiroRealm extends AuthorizingRealm { 2 3 @Resource 4 private MenuService menuService; 5 @Resource 6 private UserService userService; 7 8 /** 9 * 授權 10 */ 11 @Override 12 protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { 13 SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); 14 User user = (User) principals.getPrimaryPrincipal(); 15 List<String> permissions = menuService.getAllMenuCodeByUserId(user.getId()); 16 authorizationInfo.addStringPermissions(permissions); 17 return authorizationInfo; 18 } 19 20 /** 21 * 身份認證 22 */ 23 @Override 24 protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { 25 UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken; 26 String userName = String.valueOf(token.getUsername()); 27 // 從數據庫獲取對應用戶名的用戶 28 User user = userService.getByUserName(userName); 29 // 賬號不存在 30 if (user == null) { 31 throw new UnknownAccountException(ResultEnum.NO_THIS_USER.getValue()); 32 } 33 34 Md5Hash md5 = new Md5Hash(token.getPassword(), AppConst.MD5_SALT, 2); 35 // 密碼錯誤 36 if (!md5.toString().equals(user.getPassword())) { 37 throw new IncorrectCredentialsException(ResultEnum.PASSWORD_WRONG.getValue()); 38 } 39 40 // 賬號鎖定 41 if (user.getStatus().equals(1)) { 42 throw new LockedAccountException(ResultEnum.USER_DISABLE.getValue()); 43 } 44 ByteSource salt = ByteSource.Util.bytes(AppConst.MD5_SALT); 45 return new SimpleAuthenticationInfo(user, user.getPassword(), salt, getName()); 46 } 47 48 }
自定義完realm后需要一個配置文件但自定義的realm配置到shiro里:

1 @Configuration 2 public class ShiroConfig { 3
4 @Bean 5 public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) { 6 ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); 7 shiroFilterFactoryBean.setSecurityManager(securityManager); 8 Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); 9 filterChainDefinitionMap.put("/static/**", "anon"); 10 filterChainDefinitionMap.put("/css/**", "anon"); 11 filterChainDefinitionMap.put("/font/**", "anon"); 12 filterChainDefinitionMap.put("/js/**", "anon"); 13 filterChainDefinitionMap.put("/*.js", "anon"); 14 filterChainDefinitionMap.put("/login", "anon"); 15 filterChainDefinitionMap.put("/doLogin", "anon"); 16 filterChainDefinitionMap.put("/**", "authc"); 17 shiroFilterFactoryBean.setLoginUrl("/login"); 18 shiroFilterFactoryBean.setSuccessUrl("/"); 19 shiroFilterFactoryBean.setUnauthorizedUrl("/noauth"); 20 shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); 21 return shiroFilterFactoryBean; 22 } 23
24 /**
25 * 憑證匹配器 26 */
27 @Bean 28 public HashedCredentialsMatcher hashedCredentialsMatcher() { 29 HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher(); 30 hashedCredentialsMatcher.setHashAlgorithmName("MD5"); 31 hashedCredentialsMatcher.setHashIterations(2); 32 return hashedCredentialsMatcher; 33 } 34
35 @Bean 36 public MyShiroRealm myShiroRealm() { 37 MyShiroRealm myShiroRealm = new MyShiroRealm(); 38 myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher()); 39 return myShiroRealm; 40 } 41
42
43 @Bean 44 public SecurityManager securityManager() { 45 DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); 46 securityManager.setRealm(myShiroRealm()); 47 return securityManager; 48 } 49
50 /**
51 * 開啟shiro aop注解 52 */
53 @Bean 54 public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { 55 AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); 56 authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); 57 return authorizationAttributeSourceAdvisor; 58 } 59
60 @Bean(name = "simpleMappingExceptionResolver") 61 public SimpleMappingExceptionResolver 62 createSimpleMappingExceptionResolver() { 63 SimpleMappingExceptionResolver r = new SimpleMappingExceptionResolver(); 64 Properties mappings = new Properties(); 65 mappings.setProperty("DatabaseException", "databaseError"); 66 mappings.setProperty("UnauthorizedException", "403"); 67 r.setExceptionMappings(mappings); 68 r.setDefaultErrorView("error"); 69 r.setExceptionAttribute("ex"); 70 return r; 71 } 72
73 }
身份認證如果簡單的理解的話,你可以理解為登錄的過程。授權就是授予你權利,代表你在這個系統中有權限做什么動作,具體shiro的內容小伙伴們自行去學習吧。
7. 登錄校驗,安全攔截
在集成了shiro之后,登錄操作就需要使用到自定義的realm了,具體的登錄代碼如下:

1 /** 2 * 登錄操作 3 * 4 * @param userName 用戶名 5 * @param pwd 密碼 6 */ 7 @RequestMapping("/doLogin") 8 @ResponseBody 9 public Object doLogin(String userName, String pwd, HttpServletRequest req) throws MyException { 10 try { 11 Subject subject = SecurityUtils.getSubject(); 12 UsernamePasswordToken token = new UsernamePasswordToken(userName, pwd); 13 subject.login(token); 14 15 User userInDb = userService.getByUserName(userName); 16 // 登錄成功后放入application,防止同一個賬戶多人登錄 17 ServletContext application = req.getServletContext(); 18 @SuppressWarnings("unchecked") 19 Map<String, String> loginedMap = (Map<String, String>) application.getAttribute(AppConst.LOGINEDUSERS); 20 if (loginedMap == null) { 21 loginedMap = new HashMap<>(); 22 application.setAttribute(AppConst.LOGINEDUSERS, loginedMap); 23 } 24 loginedMap.put(userInDb.getUserName(), req.getSession().getId()); 25 26 // 登錄成功后放入session中 27 req.getSession().setAttribute(AppConst.CURRENT_USER, userInDb); 28 logService.addLog("登錄系統!", "成功!", userInDb.getId(), userInDb.getNickName(), IPUtils.getClientIPAddr(req)); 29 return super.success("登錄成功!"); 30 } catch (Exception e) { 31 User userInDb = userService.getByUserName(userName); 32 if (e instanceof UnknownAccountException) { 33 logService.addLog("登錄系統!", "失敗[" + ResultEnum.NO_THIS_USER.getValue() + "]", 0, "", IPUtils.getClientIPAddr(req)); 34 return super.fail(ResultEnum.NO_THIS_USER.getCode(), ResultEnum.NO_THIS_USER.getValue()); 35 } else if (e instanceof IncorrectCredentialsException) { 36 logService.addLog("登錄系統!", "失敗[" + ResultEnum.PASSWORD_WRONG.getValue() + "]", userInDb.getId(), userInDb.getNickName(), IPUtils.getClientIPAddr(req)); 37 return super.fail(ResultEnum.PASSWORD_WRONG.getCode(), ResultEnum.PASSWORD_WRONG.getValue()); 38 } else if (e instanceof LockedAccountException) { 39 logService.addLog("登錄系統!", "失敗[" + ResultEnum.USER_DISABLE.getValue() + "]", userInDb.getId(), userInDb.getNickName(), IPUtils.getClientIPAddr(req)); 40 return super.fail(ResultEnum.USER_DISABLE.getCode(), ResultEnum.USER_DISABLE.getValue()); 41 } else { 42 throw new MyException(ResultEnum.UNKNOW_ERROR.getCode(), "登錄操作出錯,請聯系網站管理人員。", e); 43 } 44 } 45 }
身份認證的操作交給了shiro,利用用戶名和密碼構造一個身份的令牌,調用shiro的login方法,這個時候就會進入自定義reaml的身份認證方法中,也就是上一步中的doGetAuthenticationInfo方法,具體的認證操作看上一步的代碼,無非就是賬號密碼的校驗等。身份認證的時候,通過拋出異常的方式給登錄操作返回信息,從而在登錄方法中判斷身份認證失敗后的信息,從而返回給前台進行提示。
在身份認證通過后,拿到當前登錄用戶的信息,首先放到session中,便於后續的使用。其次在放到application對象中,防止同一個賬號的多次登錄。
有了身份任何和授權自然就少不了安全校驗,在本示例中使用了一個攔截器來實現安全校驗的工作:

1 @Component 2 public class SecurityInterceptor extends HandlerInterceptorAdapter { 3 4 @Override 5 public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { 6 resp.setContentType("text/plain;charset=UTF-8"); 7 String servletPath = req.getServletPath(); 8 // 放行的請求 9 if (servletPath.startsWith("/login") || servletPath.startsWith("/doLogin") || servletPath.equals("/err")) { 10 return true; 11 } 12 if (servletPath.startsWith("/error")) { 13 resp.sendRedirect("/err"); 14 return true; 15 } 16 17 // 獲取當前登錄用戶 18 User user = (User) req.getSession().getAttribute(AppConst.CURRENT_USER); 19 20 // 沒有登錄狀態下訪問系統主頁面,都跳轉到登錄頁,不提示任何信息 21 if (servletPath.startsWith("/")) { 22 if (user == null) { 23 resp.sendRedirect(getDomain(req) + "/login"); 24 return false; 25 } 26 } 27 28 // 未登錄或會話超時 29 if (user == null) { 30 String requestType = req.getHeader("X-Requested-With"); 31 if ("XMLHttpRequest".equals(requestType)) { // ajax請求 32 ResultInfo<Object> resultInfo = new ResultInfo<>(); 33 resultInfo.setCode(ResultEnum.SESSION_TIMEOUT.getCode()); 34 resultInfo.setData(ResultEnum.SESSION_TIMEOUT.getValue()); 35 resp.getWriter().write(JSONObject.toJSONString(resultInfo)); 36 return false; 37 } 38 String param = URLEncoder.encode(ResultEnum.SESSION_TIMEOUT.getValue(), "UTF-8"); 39 resp.sendRedirect(getDomain(req) + "/login?errorMsg=" + param); 40 return false; 41 } 42 43 // 檢查是否被其他人擠出去 44 ServletContext application = req.getServletContext(); 45 @SuppressWarnings("unchecked") 46 Map<String, String> loginedMap = (Map<String, String>) application.getAttribute(AppConst.LOGINEDUSERS); 47 if (loginedMap == null) { // 可能是掉線了 48 String requestType = req.getHeader("X-Requested-With"); 49 if ("XMLHttpRequest".equals(requestType)) { // ajax請求 50 ResultInfo<Object> resultInfo = new ResultInfo<>(); 51 resultInfo.setCode(ResultEnum.LOSE_LOGIN.getCode()); 52 resultInfo.setData(ResultEnum.LOSE_LOGIN.getValue()); 53 resp.getWriter().write(JSONObject.toJSONString(resultInfo)); 54 return false; 55 } 56 String param = URLEncoder.encode(ResultEnum.LOSE_LOGIN.getValue(), "UTF-8"); 57 resp.sendRedirect(getDomain(req) + "/login?errorMsg=" + param); 58 return false; 59 } 60 String loginedUserSessionId = loginedMap.get(user.getUserName()); 61 String mySessionId = req.getSession().getId(); 62 63 if (!mySessionId.equals(loginedUserSessionId)) { 64 String requestType = req.getHeader("X-Requested-With"); 65 if ("XMLHttpRequest".equals(requestType)) { // ajax請求 66 ResultInfo<Object> resultInfo = new ResultInfo<>(); 67 resultInfo.setCode(ResultEnum.OTHER_LOGINED.getCode()); 68 resultInfo.setData(ResultEnum.OTHER_LOGINED.getValue()); 69 resp.getWriter().write(JSONObject.toJSONString(resultInfo)); 70 return false; 71 } 72 String param = URLEncoder.encode(ResultEnum.OTHER_LOGINED.getValue(), "UTF-8"); 73 resp.sendRedirect(getDomain(req) + "/login?errorMsg=" + param); 74 return false; 75 } 76 return true; 77 } 78 79 /** 80 * 獲得域名 81 */ 82 private String getDomain(HttpServletRequest request) { 83 String path = request.getContextPath(); 84 return request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path; 85 } 86 87 }
在攔截器中,首先對一些不需要校驗的請求進行放行,例如登錄動作、登錄頁面請求以及錯誤頁面等。然后獲取當前登錄的用戶,如果沒有登錄則自動跳轉到登錄頁面。在返回前台的時候,判斷請求屬於同步請求還是異步請求,如果是同步請求,直接進行頁面的跳轉,跳轉到登錄頁面。如果是異步請求,則返回前台一個json數據,提示前台登錄信息失效。這里補充一點,前台可以使用ajaxhook進行異步請求的捕獲,相當於一個前端的全局攔截器,攔截所有的異步請求,可以監視所有異步請求的返回結果,如果返回的是登錄失效,則進行跳轉到登錄頁面的操作。具體ajaxhook的使用方法請自行學習,本示例中暫時沒有使用。
下面是判斷同一個賬號有沒有多次登錄,具體方法就是使用當前的sessionId,將當前登錄用戶和請求sissionId作為一個key-value放到了application中,如果該用戶的sessionId發生了變化,說明又有一個人登錄了該賬號,然后就進行相應的提示操作。
8. 配置虛擬路徑
web項目中免不了並上傳的操作,圖片或者文件,如果上傳的是圖片,一般還要進行回顯的操作,我們不想將上傳的文件直接存放在項目的目錄中,而是放在一個自定義的目錄,同時項目還可以訪問:
這樣在進行上傳操作的時候,就可以將上傳的文件放到項目以外的目錄中,然后外部訪問的時候,通過虛擬路徑進行映射訪問。
9. 集成redis緩存
springboot的強悍就是集成一個東西太方便了,如果你不想做任何配置,只需要加入redis的依賴,然后在配置文件(本示例中配置是在數據庫中)中添加redis的鏈接信息,就可以在項目中使用redis了。
本示例中使用redis做緩存,首先寫了一個緩存的類,代碼有些長不做展示。然后在service層進行緩存的操作:
代碼中使用了double check的騷操作,防止高並發下緩存失效的問題(雖然我的示例不可能有高並發,哈哈)。另外就是緩存更新的問題,網上說的有很多,先更新數據再更新緩存,先更新緩存再更新數據庫等等,具體要看你是做什么,本示例中沒有什么需要特殊注意的地方,因此就先更新數據庫,然后再移除緩存:
10. 數據庫與實體類自動映射
在使用jdbcTemplate的時候,需要將數據庫的字段與自己定義實體類的字段進行映射,如果字段多的話,就需要寫很多代碼,每個查詢方法就需要寫一遍,當然你可以抽取出來,但若以后修改數據庫字段的話,這里還需要進行修改,很不方便擴展。工作的時候我發現了我們項目中的一個工具類,發現用到自己的項目中特別好用,在自己定義的實體類上加上注解之后,就會自動的進行關系映射。首先加入依賴:
<dependency> <groupId>org.crazycake</groupId> <artifactId>jdbctemplatetool</artifactId> <version>1.0.4-RELEASE</version> </dependency>
然后在自己定義的實體類上加上注解,注解的值和數據庫相應的字段一一對應即可:
然后在查詢方法上使用到工具類這樣進行映射:
工具類代碼:

1 /** 2 * JDBC關系映射工具類 3 * 4 * @author Oven 5 */ 6 public class VoPropertyRowMapper<T> implements RowMapper<T> { 7 8 private final Logger logger = LoggerFactory.getLogger(this.getClass()); 9 private Class<T> mappedClass; 10 private Map<String, PropertyDescriptor> mappedFields; 11 private Set<String> mappedProperties; 12 13 public VoPropertyRowMapper(Class<T> mappedClass) { 14 this.initialize(mappedClass); 15 } 16 17 private void initialize(Class<T> mappedClass) { 18 this.mappedClass = mappedClass; 19 this.mappedFields = new HashMap<>(); 20 this.mappedProperties = new HashSet<>(); 21 PropertyDescriptor[] pds = BeanUtils.getPropertyDescriptors(mappedClass); 22 23 for (PropertyDescriptor pd : pds) { 24 String propertyName = pd.getName(); 25 Method method = pd.getWriteMethod(); 26 if (method != null) { 27 Column column = this.getClassFieldColumnInfo(mappedClass, propertyName); 28 String underscoredName; 29 if (column != null) { 30 underscoredName = column.name(); 31 this.mappedFields.put(underscoredName.toLowerCase(), pd); 32 } else { 33 this.mappedFields.put(pd.getName().toLowerCase(), pd); 34 underscoredName = this.underscoreName(pd.getName()); 35 if (!pd.getName().toLowerCase().equals(underscoredName)) { 36 this.mappedFields.put(underscoredName, pd); 37 } 38 } 39 40 this.mappedProperties.add(pd.getName()); 41 } 42 } 43 44 } 45 46 private Column getClassFieldColumnInfo(Class<T> mappedClass, String propertyName) { 47 Column column = null; 48 Field[] fields = mappedClass.getDeclaredFields(); 49 50 for (Field f : fields) { 51 if (f.getName().equals(propertyName)) { 52 column = f.getAnnotation(Column.class); 53 break; 54 } 55 } 56 57 return column; 58 } 59 60 private String underscoreName(String name) { 61 if (!StringUtils.hasLength(name)) { 62 return ""; 63 } else { 64 StringBuilder result = new StringBuilder(); 65 result.append(name.substring(0, 1).toLowerCase()); 66 67 for (int i = 1; i < name.length(); ++i) { 68 String s = name.substring(i, i + 1); 69 String slc = s.toLowerCase(); 70 if (!s.equals(slc)) { 71 result.append("_").append(slc); 72 } else { 73 result.append(s); 74 } 75 } 76 77 return result.toString(); 78 } 79 } 80 81 private boolean isCheckFullyPopulated() { 82 return false; 83 } 84 85 public T mapRow(ResultSet rs, int rowNumber) throws SQLException { 86 Assert.state(this.mappedClass != null, "Mapped class was not specified"); 87 T mappedObject = BeanUtils.instantiateClass(this.mappedClass); 88 BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(mappedObject); 89 this.initBeanWrapper(); 90 ResultSetMetaData rsmd = rs.getMetaData(); 91 int columnCount = rsmd.getColumnCount(); 92 Set<String> populatedProperties = this.isCheckFullyPopulated() ? new HashSet<>() : null; 93 94 for (int index = 1; index <= columnCount; ++index) { 95 String column = JdbcUtils.lookupColumnName(rsmd, index); 96 PropertyDescriptor pd = this.mappedFields.get(column.replaceAll(" ", "").toLowerCase()); 97 if (pd != null) { 98 try { 99 Object value = this.getColumnValue(rs, index, pd); 100 if (this.logger.isDebugEnabled() && rowNumber == 0) { 101 this.logger.debug("Mapping column '" + column + "' to property '" + pd.getName() + "' of type " + pd.getPropertyType()); 102 } 103 bw.setPropertyValue(pd.getName(), value); 104 if (populatedProperties != null) { 105 populatedProperties.add(pd.getName()); 106 } 107 } catch (NotWritablePropertyException var14) { 108 throw new DataRetrievalFailureException("Unable to map column " + column + " to property " + pd.getName(), var14); 109 } 110 } 111 } 112 113 if (populatedProperties != null && !populatedProperties.equals(this.mappedProperties)) { 114 throw new InvalidDataAccessApiUsageException("Given ResultSet does not contain all fields necessary to populate object of class [" + this.mappedClass + "]: " + this.mappedProperties); 115 } else { 116 return mappedObject; 117 } 118 } 119 120 private void initBeanWrapper() { 121 } 122 123 private Object getColumnValue(ResultSet rs, int index, PropertyDescriptor pd) throws SQLException { 124 return JdbcUtils.getResultSetValue(rs, index, pd.getPropertyType()); 125 } 126 127 }
11. 項目代碼和依賴以及靜態資源分別打包
之前遇到一個問題,springboot打包之后是一個jar文件,如果將所有依賴也打到這個jar包中的話,那么這個jar包動輒幾十兆,來回傳輸不說,如果想改動其中的一個配置內容,還異常的繁瑣,因此,將項目代碼,就是自己寫的代碼打成一個jar包(一般只有幾百k),然后將所有的依賴打包到一個lib目錄,然后再將所有的配置信息以及靜態文件打包到resources目錄,這樣,靜態文件可以直接進行修改,瀏覽器清理緩存刷新即可出現改動效果,而且打包出來的項目代碼也小了很多,至於依賴,一般都是不變的,所以也沒必要每次都打包它。具體操作就是在pom.xml中增加一個插件即可,代碼如下:
代碼太長,不做展示
12. 接口限流
具體應用場景很多,這里不再一一舉例,本工程只是做了一個demo來測試接口限流的功能,代碼大部分都是從別人的工程里摘出來的,除了自定義異常,因為需要用自己的統一異常捕獲去處理,這里給一個鏈接(https://github.com/Senssic/sc-whorl)。
首先編寫一個限流的注解,其中limitType代表限流的類型,也接口級別的限流,還是用戶級別的限流。個人覺得用戶級別的限流比較常用,因此demo中以用戶級別限流作為示例,限制一個ip在10s內只能訪問某個接口5次。
這里主要就是自定義注解的處理類,其中運用了lua腳本來處理請求次數,在下不才,不懂lua,但是其核心思路就是使用redis記錄了用戶請求的次數,從而進行限流的控制

1 @Around("execution(public * *(..)) && @annotation(com.oven.limitation.Limit)") 2 public Object interceptor(ProceedingJoinPoint pjp) { 3 MethodSignature signature = (MethodSignature) pjp.getSignature(); 4 Method method = signature.getMethod(); 5 Limit limitAnnotation = method.getAnnotation(Limit.class); 6 LimitType limitType = limitAnnotation.limitType(); 7 String key; 8 int limitPeriod = limitAnnotation.period(); 9 int limitCount = limitAnnotation.count(); 10 switch (limitType) { 11 case IP: 12 @SuppressWarnings("ConstantConditions") 13 HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); 14 key = AppConst.LIMIT_KEY_PREFIX + IPUtils.getClientIPAddr(req); 15 break; 16 case CUSTOMER: 17 key = limitAnnotation.key(); 18 break; 19 default: 20 key = StringUtils.upperCase(method.getName()); 21 } 22 ImmutableList<String> keys = ImmutableList.of(StringUtils.join(limitAnnotation.prefix(), key)); 23 try { 24 String luaScript = buildLuaScript(); 25 RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class); 26 Number count = limitRedisTemplate.execute(redisScript, keys, limitCount, limitPeriod); 27 if (count != null && count.intValue() <= limitCount) { 28 return pjp.proceed(); 29 } else { 30 throw new RuntimeException(ResultEnum.OVER_LIMIT_ERROR.getValue()); 31 } 32 } catch (Throwable e) { 33 if (e instanceof RuntimeException) { 34 log.error(AppConst.ERROR_LOG_PREFIX + "{}請求{}超過次數限制!", key, method.toString()); 35 } 36 throw new LimitException(ResultEnum.OVER_LIMIT_ERROR.getCode(), ResultEnum.OVER_LIMIT_ERROR.getValue()); 37 } 38 } 39 40 /** 41 * 限流 腳本 42 * 43 * @return lua腳本 44 */ 45 public String buildLuaScript() { 46 return "local c" + 47 "\nc = redis.call('get',KEYS[1])" + 48 // 調用不超過最大值,則直接返回 49 "\nif c and tonumber(c) > tonumber(ARGV[1]) then" + 50 "\nreturn c;" + 51 "\nend" + 52 // 執行計算器自加 53 "\nc = redis.call('incr',KEYS[1])" + 54 "\nif tonumber(c) == 1 then" + 55 // 從第一次調用開始限流,設置對應鍵值的過期 56 "\nredis.call('expire',KEYS[1],ARGV[2])" + 57 "\nend" + 58 "\nreturn c;"; 59 }
13. 項目啟動
到現在都沒有貼一個項目的目錄結構,先來一張。目錄中項目跟目錄下的demo.sh就是啟動腳本,當時從網上抄襲改裝過來的,源代碼出自那位大師之手我就不知道了,先行謝過。在部署到服務器的時候,如果服務器上安裝好了jdk、maven、git,每次修改完代碼,直接git pull下來,然后mvn package打包,然后直接./demo.sh start就可以啟動項目,方便快速。慢着,忘記了,如果你提交到github中的application.properties中的數據源配置信息是開發環境的話,那么你在打包之后,target/resources中的application.properties中的數據源需要改成開發環境才可以啟動。當然如果你嫌麻煩,可以直接將開發環境的數據源配置push到github中,安不安全就要你自己考慮了。
14. 總結
示例中可能還有一些細節沒有說到,總之這個項目是慢慢的添磚添瓦弄出來的,自己在寫很多其他的項目的時候,都是以此項目為模板進行改造出來的,個人感覺很實用很方便,用着也很舒服。github地址:https://github.com/503612012/demo歡迎收藏。