一個項目的SpringCloud微服務改造過程

  • 2019 年 10 月 3 日
  • 筆記

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。

作者: 黃玲峰

來源:宜信技術學院