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

发布于:2019 年 8 月 25 日 08:00

前阿里 P9 技术专家 李运华,正在传授十几年架构实战心法,超过 46000 人跟随学习,点击查看 >>
一个项目的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 静态资源文件路径如图:

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

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 区分来自前后端的请求,反向代理请求对应到不同的服务去。

2.2 方便联调效率,引入 swagger

swagger 是后端接口展示的插件,通过修改拦截器代码,mock 登录对象免登录,直接访问接口进行前后端的调试。在 swagger 插件上可以看到具体接口请求路径和参数、参数是否必须、返回值、接口统计信息等。

  • 接口统计信息

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

  • 请求参数和路径

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

  • 返回值

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

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

阅读数:5456 发布于:2019 年 8 月 25 日 08:00

更多 架构、微服务、性能调优 相关课程,可下载【 极客时间 】App 免费领取 >

评论 (1 条评论)

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