一個專案的SpringCloud微服務改造過程
SSO是公司一個已經存在了若干年的專案,後端採用SpringMVC、MyBatis,資料庫使用MySQL,前端展示使用Freemark。今年,我們對該專案進行了一次革命性的改進,改造成SpringCloud架構,並且把前後端分離,前端採用Vue框架。
一、使用SpringCloud架構進行改造
1.1 為什麼使用SpringCloud
SpringCloud的核心是SpringBoot,相比較於傳統的Spring,SpringCloud具有以下優點:
- 部署簡單,SpringBoot內建了Tomcat容器,可以將程式直接編譯成一個jar,通過java-jar來執行。
- 編碼簡單,SpringBoot只需要在pom檔案中新增一個starter-web依賴,即可幫助開發者快速啟動一個web容器,非常方便。
- 配置簡單,SpringBoot可以通過簡單的註解方式來代替原先Spring非常複雜的xml方式。如果我想把一個普通的類交給Spring管理,只需要新增@Configuration和@Bean兩個註解即可。
- 監控簡單,我們可以引入spring-boot-start-actuator依賴,直接使用REST方式來獲取程序的執行期效能引數,從而達到監控的目的。
1.2 一個常規專案都需要改造哪些部分
1.2.1 配置檔案
SSO專案改造前充斥著大量的配置檔案,主要包含以下這些部分:
- 靜態資源相關
- 資料來源
- mybatis配置
- redis配置
- 事務
- 攔截器攔截內容
- 監聽器、過濾器
- 元件掃描路徑配置
本文著重介紹以下幾個部分:
1)靜態資源處理
SpringMVC中,如果mvc:interceptors
配置的URL規則如下,則不會攔截靜態資源。
<mvc:mapping path="/*.do" />
但是如果配置的是:
<mvc:mapping path="/**" />
方案1: 在web.xml中配置<servlet-name>default</servlet-name>
,用defaultServlet
先處理請求如:
<servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.jpg</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.png</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.gif</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.ico</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.gif</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.js</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>*.css</url-pattern> </servlet-mapping>
方案2:使用<mvc:resources />
標籤宣告靜態資源路徑
<mvc:resources mapping="/resources/js/**" location="/js/" /> <mvc:resources mapping="/resources/images/**" location="/images/" /> <mvc:resources mapping="/resources/css/**" location="/css/" />
方案3:使用mvc:default-servlet-handler/
標籤
SpringBoot解決方案:繼承WebMvcConfigurerAdapter實現addResourceHandlers方法。
public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/**") .addResourceLocations("classpath:/resource/")//sso靜態資源 .addResourceLocations("classpath:/META-INF/resources/")//swagger靜態資源 .setCachePeriod(0);//0表示不快取 }
sso靜態資原始檔路徑如圖:
2)攔截器
SpringMVC配置檔案內容:
攔截任何請求並且初始化引數,有些請求是不需要攔截的,有的請求登入後不需要經過許可權校驗直接放行。
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**" /> <bean class="自定義攔截器PermissionInterceptor"> <!-- 未登入即可訪問的地址 --> <property name="excludeUrls"> <list><value>請求地址<value></list> </property> <!-- 只要登入了就不需要攔截的資源 --> <property name="LogInExcludeUrls"> <list><value>請求地址<value></list> </property> </bean> </mvc:interceptor> </mvc:interceptors>
SpringBoot中新增攔截器只需繼承WebMvcConfigurerAdapter,並重寫addInterceptors方法即可。
/*** 攔截器 * @param registry */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(permissionInterceptor). addPathPatterns("/**"); super.addInterceptors(registry); }
自定義的攔截器需要初始化一些引數,因此需要在註冊攔截器之前註冊,這裡我們設定為懶載入。免登入攔截的路徑,以及登入後不需要判斷許可權的路徑都寫在yml檔案了,通過系統環境變數Environment獲取值。
@Autowired @Lazy private PermissionInterceptor permissionInterceptor; @Autowired private Environment environment; /** * */ @Bean public PermissionInterceptor permissionInterceptor() { PermissionInterceptor permissionInterceptor = new PermissionInterceptor(); List<String> excludeUrls = Arrays.asList(environment.getProperty("intercept.exclude.path").split(",")); List<String> commonUrls = Arrays.asList(environment.getProperty("intercept.login.exclude.path").split(",")); permissionInterceptor.setCommonUrls(commonUrls); permissionInterceptor.setExcludeUrls(excludeUrls); return permissionInterceptor; }
3)資料庫和mybatis配置
A、資料來源配置
資料來源注入的三種情況:
【情況一】
- 條件:不引⼊druid-spring-boot-starter只依賴druid.jar,不指定spring.datasource.type。
- 結果:注入的資料來源是tomcat的資料來源。
- 解析:依賴的mybatis-spring-boot-starter工程依賴了tomcat的資料來源,spring-boot-autoconfigure-starter的DataSourceAutoConfiguration自動注入類會在不指定資料來源的情況下,判斷路徑中是否存在預設的4種資料來源(Hikari,Tomcat,Dbcp,Dbcp2)的其一,如果有就注入。
【情況二】
- 條件:不引入druid-spring-boot-starter只依賴druid.jar ,指定spring.datasource.type為DruidDataSource。
- 結果:注入了DruidDataSource資料來源,但配置檔案中的druid配置不會生效。
- 解析: 指定了依賴的資料來源後,spring自動注入的starter會將指定的資料來源注入,yml指定了druid資料來源。@ConfigurationProperties註解的DataSourceProperties沒處理druid部分的效能引數屬性,只處理了資料來源部分的屬性。
【情況三】
- 條件:引⼊ druid-spring-boot-starter 不依賴druid.jar,指定spring.datasource.type為DruidDataSource。
- 結果:注入了DruidDataSource資料來源, 配置檔案中的druid配置也會生效。
- 解析:druid-spring-boot-starter自動配置類會在DataSourceAutoConfiguration之前先建立資料來源,並且@ConfigurationProperties注入的DataSourceProperties包含了配置檔案中druid的屬性。
pom.xml依賴:
<!-- 情況一、二 測試引入的依賴 --> <!--<dependency>--> <!--<groupId>com.alibaba</groupId>--> <!--<artifactId>druid</artifactId>--> <!--<version>${druid.version}</version>--> <!--</dependency>--> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.10</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>RELEASE</version> </dependency>
yml配置:
spring: datasource: type: com.alibaba.druid.pool.DruidDataSource # 當前資料來源操作型別 driver-class-name: com.mysql.jdbc.Driver # mysql驅動包 url: jdbc:mysql://yourURL # 資料庫名稱 username: yourusername password: yourpassword druid: initial-size: 5 # 初始化大小 min-idle: 5 # 最小 max-active: 20 # 最大 max-wait: 60000 # 連線超時時間 time-between-eviction-runs-millis: 60000 # 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連線,單位是毫秒 min-evictable-idle-time-millis: 300000 # 指定一個空閒連線最少空閒多久後可被清除,單位是毫秒 validationQuery: select 'x' test-while-idle: true # 當連線空閒時,是否執行連線測試 test-on-borrow: false # 當從連線池借用連線時,是否測試該連線 test-on-return: false # 在連線歸還到連線池時是否測試該連線 filters: config,wall,stat
B、MyBatis配置
通過引入mybatis-spring-boot-starter依賴,可以簡單配置mybatis上手使用。
下面簡單分析mybatis-starter的原始碼以及如何配置mybatis。
先看mybatis-spring-boot-starter中mybatis-spring-boot-autoconfigure的spring.factories檔案
# Auto Configure org.springframework.boot.autoconfigure.EnableAutoConfiguration=\org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
可以看到自動注入類是MybatisAutoConfiguration,我們從這個類入手分析可以知道,必須先建立好了資料來源後,才會載入mybatis的sqlSessionFactory。
@EnableConfigurationProperties({MybatisProperties.class})註解指定了配置檔案中 prefix = "mybatis" 那部分屬性有效,這部分屬性值將注入到已建立的SqlSessionFactoryBean中,最後生成SqlSessionFactory物件。
@Configuration //當SqlSessionFactory,SqlSessionFactoryBean存在的情況下載入當前Bean @ConditionalOnClass({SqlSessionFactory.class, SqlSessionFactoryBean.class}) //當指定資料來源在容器中只有一個或者有多個但是隻指定首選資料來源 @ConditionalOnSingleCandidate(DataSource.class) @EnableConfigurationProperties({MybatisProperties.class}) //當資料來源注入到Spring容器後才開始載入當前Bean @AutoConfigureAfter({DataSourceAutoConfiguration.class}) public class MybatisAutoConfiguration implements InitializingBean { private final MybatisProperties properties; @Bean @ConditionalOnMissingBean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setVfs(SpringBootVFS.class); //設定mybatis配置檔案所在路徑 if (StringUtils.hasText(this.properties.getConfigLocation())) { factory.setConfigLocation(this.resourceLoader.getResource (this.properties.getConfigLocation())); } } //設定其他MyBatisProperties物件中有的屬性略.... return factory.getObject(); } }
MybatisProperties含有的屬性:
@ConfigurationProperties(prefix = "mybatis" ) public class MybatisProperties { public static final String MYBATIS_PREFIX = "mybatis"; private static final ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver(); private String configLocation; private String[] mapperLocations; private String typeAliasesPackage; private Class<?> typeAliasesSuperType; private String typeHandlersPackage; private boolean checkConfigLocation = false; private ExecutorType executorType; private Properties configurationProperties; @NestedConfigurationProperty private Configuration configuration; }
C、使用mybatis
- 配置檔案:
application.yml
mybatis: config-location: classpath:mybatis.xml # mybatis配置檔案所在路徑 type-aliases-package: com.creditease.permission.model # 所有Entity別名類所在包 mapper-locations: classpath:mybatis/**/*.xml
從上面的MybatisProperties可以看出,mybatis可以指定一些configuration,比如自定義攔截器pageHelper。
mybatis.xml
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"></plugin> <plugin interceptor="com.creditease.permission.manager.MybatisInterceptor"></plugin> </plugins> </configuration>
- 在啟動類上加入@MapperScan註解
@MapperScan("com.creditease.permission.dao")//mapper類所在目錄 public class SsoApplication { public static void main(String[] args) { SpringApplication.run(SsoApplication.class, args); } }
4)事務
Spring事務有兩種處理方式:
- 程式設計式
用TransactionTemplate或者直接使用底層的PlatformTransactionManager將事務程式碼寫在業務程式碼中。
優點:可以在程式碼塊中處理事務,比較靈活。
缺點:對程式碼具有侵入性。
- 宣告式
採用@Transactional註解或者基於配置檔案方式,在方法前後進行攔截。
優點:非侵入性不會汙染程式碼。
缺點:事務只能在方法和類上控制,粒度較小。
A、使用@Transactional註解
非SpringBoot工程,需要在配置檔案中加入配置:
<tx:annotation-driven/>
SpringBoot工程可以用@EnableTransactionManagement註解代替上面的配置內容。
B、採用配置檔案方式
之前的sso是基於配置的方式,配置程式碼如下:
<aop:config> <aop:pointcut expression="execution(public * com.creditease.permission.service.impl.*Impl.*(..))" id="pointcut"/> <aop:advisor advice-ref="txAdvice" pointcut-ref="pointcut"/> </aop:config> <tx:advice id="txAdvice" transaction-manager="transactionManager"> <tx:attributes> <tx:method name="query*" propagation="REQUIRED" read-only="true"/> <tx:method name="find*" propagation="REQUIRED" read-only="true"/> <tx:method name="save*" propagation="REQUIRED"/> <tx:method name="delete*" propagation="REQUIRED"/> <tx:method name="add*" propagation="REQUIRED"/> <tx:method name="modify*" propagation="REQUIRED"/> </tx:attributes> </tx:advice>
改造後的SpringBoot基於Java程式碼:
@Aspect @Configuration public class TransactionAdviceConfig { /** * 指定切入點 */ private static final String AOP_POINTCUT_EXPRESSION = "execution(public * com.creditease.permission.service.impl.*Impl.*(..))"; @Resource DruidDataSource dataSource; /** * 指定處理事務的PlatformTransactionManager * @return */ @Bean public DataSourceTransactionManager transactionManager() { return new DataSourceTransactionManager(dataSource); } /** * 指定切入點處理邏輯,執行事務 * @return */ @Bean public TransactionInterceptor txAdvice() { DefaultTransactionAttribute txAttrRequired = new DefaultTransactionAttribute(); txAttrRequired.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); DefaultTransactionAttribute txAttrRequiredReadonly = new DefaultTransactionAttribute(); txAttrRequiredReadonly.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); txAttrRequiredReadonly.setReadOnly(true); NameMatchTransactionAttributeSource source = new NameMatchTransactionAttributeSource(); source.addTransactionalMethod("query*", txAttrRequiredReadonly); source.addTransactionalMethod("find*", txAttrRequiredReadonly); source.addTransactionalMethod("save*", txAttrRequired); source.addTransactionalMethod("delete*", txAttrRequired); source.addTransactionalMethod("add*", txAttrRequired); source.addTransactionalMethod("modify*", txAttrRequired); return new TransactionInterceptor(transactionManager(), source); } /** * Advisor組裝配置,將Advice的程式碼邏輯注入到Pointcut位置 * @return */ @Bean public Advisor txAdviceAdvisor() { AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut(); pointcut.setExpression(AOP_POINTCUT_EXPRESSION); return new DefaultPointcutAdvisor(pointcut, txAdvice()); }
5)全域性異常處理
一般編碼時有異常我們都會try-catch捕獲異常,有時為了區分不同的異常還會一次catch多個異常,大量的try-catch語句,這樣使得程式碼也不夠優雅;一個相同的異常處理寫多次程式碼也比較冗餘,所以引入全域性的異常處理非常必要。
改造前的異常處理配置檔案:
<!--定義異常處理頁面--> <bean id="exceptionResolver" class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver"> <property name="exceptionMappings"> <props> <prop key="com.creditease.permissionapi.exception.NopermissionException">/permission/noSecurity</prop> </props> </property> </bean>
使用SimpleMappingExceptionResolver類處理異常,設定自定義異常型別NopermissionException,以及異常發生後的請求路徑/permission/noSecurity。
SpringBoot中採用@RestControllerAdvice或者@ControllerAdvice設定全域性異常類。這兩者區別類似於@Controller和@RestController註解。
SSO中定義了三種全域性的異常處理:普通的Exception處理;自定的NopermissionException異常和引數校驗異常。
全域性異常處理程式碼如下:
@Configuration @Slf4j @RestControllerAdvice public class GlobalExceptionConfig { //無許可權處理 @ExceptionHandler(value = {NopermissionException.class}) public void noPermissionExceptionHandler(HttpServletRequest request, Exception ex, HttpServletResponse response, @Value("${sso.server.prefix}") String domain) throws IOException { printLog(request,ex); response.sendRedirect("跳轉到無許可權頁面地址"); } //引數校驗處理 @ExceptionHandler(value = {BindException.class}) public ResultBody BindExceptionHandler(BindException bindException){ List<ObjectError> errors = bindException.getBindingResult().getAllErrors(); //這個ResultBody是一個返回結果物件,這裡需要返回json,裡面包含了狀態碼和提示資訊 return ResultBody.buildFailureResult(errors.get(0).getDefaultMessage()); } //所有未捕獲的異常處理邏輯 @ExceptionHandler(value = {Exception.class}) public ResultBody exceptionHandler(HttpServletRequest request,Exception ex){ printLog(request,ex); return ResultBody.buildExceptionResult(); } //將請求引數和異常打印出來,結合@slf4j註解 public void printLog(HttpServletRequest request,Exception ex){ String parameters = JsonHelper.toString(request.getParameterMap()); log.error("url>>>:{},params>>>:{} ,printLog>>>:{}",request.getRequestURL(),parameters,ex); } }
@RestControllerAdvice結合@Validation,可以對Bean進行校驗,校驗不通過會丟擲BindException異常。通過註解可以少寫if-else程式碼,判斷請求的介面引數是否為空,提高程式碼的美觀性。例如:
//常規做法 if(StringUtils.isEmpty(ssoSystem.getSysCode()) //SSO做法 //在Controller請求方法上新增@Valid註解 @RequestMapping(value = "/add", method = RequestMethod.POST) public ResultBody add(@Valid @RequestBody SsoSystem ssoSystem) { } //在需要處理的SsoSystem Bean的屬性上加@NotNull註解 @NotNull(message = "系統編號不能為空") private String sysCode;
當sysCode傳入引數為空時,就會丟擲BindException被全域性的異常處理類,捕獲處理返回json格式的引數:
{ "resultCode":2, "resultMsg":"系統編號不能為空", "resultData":null }
1.3 注意事項
1.3.1 內建tomcat版本太高引發的問題
SpringBoot1.5預設使用內嵌tomcat8.5版本,而原來SpringMVC的SSO部署在tomcat7上。tomcat的升級對這次改造影響最明顯的就是cookie。 tomcat8後採用的cookie校驗協議是Rfc6265CookieProcessor。該協議要求domain的命名必須遵循以下規則:
- 必須是1-9、a-z、A-Z、. 、- 這幾個字元組成。
- 必須是數字或字母開頭 (之前是以.creditease.corp 會報錯tomcat cookie domain validation異常,最後改成了 creditease.corp)。
- 必須是數字或字母結尾。
二、前後端分離
2.1 解決跨域問題
由於是兩個不同的應用,必然會有兩個不同的埠。不同的埠就會有跨域問題,SSO採用的方式是通過nginx區分來自前後端的請求,反向代理請求對應到不同的服務去。
- sso.creditease.com對應的是後端的應用服務。
- sso.creditease.com/web對應的是前端的靜態資源應用服務。
2.2 方便聯調效率,引入swagger
swagger是後端介面展示的外掛,通過修改攔截器程式碼,mock登入物件免登入,直接訪問介面進行前後端的除錯。在swagger外掛上可以看到具體介面請求路徑和引數、引數是否必須、返回值、介面統計資訊等。
-
介面統計資訊
-
請求引數和路徑
- 返回值
2.3 跳轉介面修改
之前是通過SpringMvc的modeAndview方式跳轉的,現在做了兩種處理:
- 改成restful介面的形式,前端控制跳轉然後直接獲取資料。
- 直接通過response.sendRedirect跳轉頁面。
注意:老程式碼跳轉採用的是通過SpringMvc在return的頁面路徑前加redirect的形式,如:return "redirect:index",這樣預設會在return的URL後加jessionID。
2.4 靜態資源地址變更可能引發的問題
特別需要注意程式碼中的相關校驗路徑的地方。比如在這次改造過程中路徑修改會影響以下幾個方面。
- 選單許可權校驗的時候,之前人、角色和路徑已經綁定了,修改選單訪問路徑會導致沒許可權。
- 掃碼登入的介面判斷了refer來源,修改路徑會導致請求失敗。
- 之前的sso-dome工程引用了靜態資源,修改路徑會報404。
作者: 黃玲峰
來源:宜信技術學院