「如何实现流动式软件发布」线上课堂开课啦,快来报名参与课堂抽奖吧~ 了解详情
写点什么

一个项目的 SpringCloud 微服务改造过程

2019 年 8 月 25 日

一个项目的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@Lazyprivate PermissionInterceptor permissionInterceptor;@Autowiredprivate Environment environment;
/****/@Beanpublic 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 Configureorg.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@Configurationpublic 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@RestControllerAdvicepublic 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 区分来自前后端的请求,反向代理请求对应到不同的服务去。



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。


本文转载自公众号宜信技术学院(ID:CE_TECH)


原文链接


https://mp.weixin.qq.com/s/ZPzxVl9YDrKo1FXYkLwNCA


2019 年 8 月 25 日 08:006004

评论 1 条评论

发布
用户头像
这个swagger界面看着好像是根据我的开源项目swagger-bootstrap-ui 改的啊
2019 年 08 月 27 日 09:35
回复
没有更多了
发现更多内容

当我们谈到ThreadLocal的时候,我们在谈什么?

Jason

Java 多线程 ThreadLocal

一个平凡程序员的年度总结

小智

程序员 人生

聊聊苹果账号的那些事儿

不要艾特我

iphone

一次线上服务高 CPU 占用优化实践

张亚

性能优化 JVM cpu

Service Worker in Action

xgqfrms

Service Worker Web Worker

Python3.6.1官方文档练习——初入江湖(一)

小匚

Python python3.x 入门

maven私服搭建

kcnf

maven

《零基础学Java》 FAQ 之 1-HelloWorld程序发生了ClassNotFound错误怎么解决

臧萌

Java Hello World !

Kubernetes 将迎来首个 LTS 版本

倪朋飞

Kubernetes 容器 微服务

Graylog部署文档

蚍蜉

Linux 开源 工具 日志分析

centos7 maven私服自动启动

kcnf

关于GDB你需要知道的技巧

helloworld

c c++ C#

写字工具更新史

Bonaparte

学习 读书笔记

【译】【UX】一个页面可以有多个面包屑导航吗?

Yukun

用户研究 UX 面包屑导航

从删库到跑路?

岳老三

产品 职业 产品经理 职业素养 职业道德

C++定时器的实现

helloworld

c c++ C#

内存对齐

helloworld

c c++ C#

Redis 6.0 新特性-多线程连环13问!

牧码哥

redis 多线程 io

css常见问题总结

靖仙

CSS css3

浅谈SpringCloud之服务注册中心Eureka

北漂码农有话说

MySQL中 int(11)和 int(10) 到底有没有区别?

周三不加班

MySQL 字符宽度 数据库数据类型

快捷考勤打卡设置

Megatron7

ios

从一道面试题来看计算机基础知识的重要性

周三不加班

数组 堆栈 函数栈 函数栈调用

Bash 的4种运行模式

Megatron7

bash Linux DevOps Shell

业务代码的救星——Java 对象转换框架 MapStruct 妙用

周三不加班

MapStruct 对象转换

《零基础学Java》 FAQ 之 2-Java版本那点事儿

臧萌

Java

《TCP/IP详解》概述

网瘾少年SEC

TCP 网络协议 IP

各大公司面试题分类整理

是小毛吖

后端 面试题

开源商业模式促进金融业科技生态的发展

fino星君

开源 金融科技

字节流(InputStream/OutputStream)

Howe

Java 工作流

利用goaccess分析nginx日志

Megatron7

nginx Linux

一个项目的SpringCloud微服务改造过程-InfoQ