39

一个项目的SpringCloud微服务改造过程-wx5c2da66615f74的博客

 4 years ago
source link: https://blog.51cto.com/14159827/2426929
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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静态资源文件路径如图:

swagger

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);
    }
}

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插件上可以看到具体接口请求路径和参数、参数是否必须、返回值、接口统计信息等。

  • 接口统计信息
    swagger

  • 请求参数和路径

请求说明

返回结果

2.3 跳转接口修改

之前是通过SpringMvc的modeAndview方式跳转的,现在做了两种处理:

  • 改成restful接口的形式,前端控制跳转然后直接获取数据。
  • 直接通过response.sendRedirect跳转页面。

注意:老代码跳转采用的是通过SpringMvc在return的页面路径前加redirect的形式,如:return "redirect:index",这样默认会在return的URL后加jessionID。

2.4 静态资源地址变更可能引发的问题

特别需要注意代码中的相关校验路径的地方。比如在这次改造过程中路径修改会影响以下几个方面。

  • 菜单权限校验的时候,之前人、角色和路径已经绑定了,修改菜单访问路径会导致没权限。
  • 扫码登录的接口判断了refer来源,修改路径会导致请求失败。
  • 之前的sso-dome工程引用了静态资源,修改路径会报404。

作者: 黄玲峰

来源:宜信技术学院


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK