From Spring to Spring Boot

Frank Chen <chenhm@gmail.com>

这是关于spring核心功能的小指南,方便快速理解spring,需要有一点Spring使用基础,样例代码可以在 https://github.com/chenhm/from-spring-to-spring-boot 找到。

1. spring context

1.1. 创建Container

Spring核心是个ioc container,所有实现 org.springframework.context.ApplicationContext 接口的类都是spring提供的container,可以根据需要选择,常见的有

  1. AnnotationConfigApplicationContext

    根据扫描到的注解创建container, 充分利用注解的便利性

    @Configuration
    @ComponentScan({"com.chenhm"}) (3)
    @ImportXml("classpath:com/company/data-access-config.xml") (4)
    public class AppLocal {
        public static void main(String[] args) {
            AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); (1)
            ctx.getEnvironment().setActiveProfiles("local");
            ctx.register(AppLocal.class);  (2)
            ctx.refresh();
            ctx.registerShutdownHook();
        }
    
        @Bean
        @Profile("local")
        public DataSource local() {
            return initDataSource();
        }
    }
    1. 创建一个空的Container

    2. 注入当前类,注意这是个 @Configuration

    3. 扫描 com.chenhm 包查找 @Component 注解

    4. 导入xml配置的bean

    使用上面配置类的DataSource:

    package com.chenhm;
    
    @Repository
    public class JdbcFooRepository implements FooRepository {
    
        @Autowired
        private DataSource dataSource;
    
        // ...
    }
  2. ClassPathXmlApplicationContext

    根据classpath中的xml配置文件创建container,配置繁琐,但更灵活。

    ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext.xml");

    下面这个xml配置与上面注解的方式是等效的

    <context:annotation-config />
    <context:component-scan base-package="com.chenhm" />
  3. XmlWebApplicationContext

    spring在web应用中的默认container,通常用下面的方式初始化

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/applicationContext*.xml</param-value>
    </context-param>
    
    <listener>
       <listener-class>
            org.springframework.web.context.ContextLoaderListener
       </listener-class>
    </listener>

    或者

     <servlet>
       <servlet-name>dispatcher</servlet-name>
       <servlet-class>
         org.springframework.web.servlet.DispatcherServlet
       </servlet-class>
       <init-param>
         <param-name>contextConfigLocation</param-name>
         <param-value>/WEB-INF/spring/dispatcher-config.xml</param-value>
       </init-param>
       <load-on-startup>1</load-on-startup>
     </servlet>

1.2. 常用配置

1.2.1. ApplicationContextAware

当Spring识别到ApplicationContextAware后,会将当前容器注入该对象,方便操作容器

@Bean
public class MyContext implements ApplicationContextAware {

   private static ApplicationContext appContext;

   @Override
   public void setApplicationContext(ApplicationContext applicationContext)
         throws BeansException {
      appContext = applicationContext;
   }

   public static <T> T getBean(Class<T> clazz) {
      return appContext.getBean(clazz);
   }
}

然后可以在任何位置访问

MyClass myClass = MyContext.getBean(MyClass.class)

2. spring aop

2.1. AOP概念

IoC解决了对象依赖问题,AOP则可以处理代码的通用逻辑,大大简化编码。在AOP以前,我们通常使用模版类提供的回调接口或interceptor来实现,比如servlet filter接口。由于需要预先设计接口,这种方式并不灵活直观。AOP则可以运行时动态拦截代码,插入通用逻辑,提供了极高的便利。拦截代码主要依赖动态代理(仅针对接口)和字节码修改技术。另外我们也可以使用Load-time instrumentation和Compile-time instrumentation,但一个需要Java agent,使用起来不够方便,一个只能在Compile-time做,不够灵活,当然instrumentation也有优势,它可以脱离容器运行。

Spring AOP 有几个核心概念:

  • Join point: 连接点,定义在哪里(哪些点)加入你的逻辑功能,对于Spring

  • Pointcut: 切入点,即一组Join point,Spring默认使用AspectJ的表达式语法匹配

  • Advice: 通知,指拦截到jointpoint之后所要做的事情,Spring AOP中分为前置通知(Before advice)、后置通知(AfterreturningAdvice)、异常通知(ThrowAdvice)、最终通知(AfterThrowing)、环绕通知(AroundAdvice)。使用AspectJ annotation 参考 http://docs.spring.io/spring/docs/current/spring-framework-reference/html/aop.html

  • Aspect: 切面,Advice和Pointcut的组合,在Spring中也叫 advisor,参考下面的spring事务配置理解

    <tx:advice id="txAdvice" transaction-manager="txManager">
       <tx:attributes>
          <tx:method name="get*" read-only="true"/>
          <tx:method name="*"/>
       </tx:attributes>
    </tx:advice>
    
    <aop:config>
       <aop:pointcut id="userServicePointCut" expression="within(com.chenhm.dao.*)"/>
       <aop:advisor advice-ref="txAdvice" pointcut-ref="userServicePointCut"/>
    </aop:config>
  • Introduction: 引入,Introduction 可以在运行期给一个class增加新的接口并指定接口的实现,也可以添加方法或Field

  • Target object: 就是advised object,在spring中永远是代理对象

  • AOP proxy: JDK dynamic proxy 或 CGLIB proxy,用于实现 Aspect

  • Weaving: 织入,应用 Aspect 创建 advised object 的过程,可以在compile time (例如AspectJ compiler), load time 或 runtime。Sping 的 weaving 发生在 runtime.

2.2. 自定义切面

除了上面xml方式配置切面外,Spring还使用aspectj注解创建切面,例子如下:

@Aspect (1)
@Component (2)
public class LogAspect {
    private Logger logger = LoggerFactory.getLogger(getClass());

    @Before("execution(public * org.springframework.data.rest.webmvc.RepositoryEntityController.get*(..)) && args(resourceInformation,..)") (3)
    public void before(JoinPoint jp, RootResourceInformation resourceInformation) {
        logger.info("before " + jp); (4)
    }
}
  1. 使用 @Aspect 注解标记切面类

  2. @Component 使spring在容器内创建该类,也可通过xml配置让spring感知此类

  3. Pointcut声明,注意参数需要用args标记

  4. JoinPoint可以获得当前方法和参数信息

2.3. 利用子定义注解创建切面

Spring本身大量使用了自定义注解,大大方便了开发者,我们也可以定义自己的注解配合切面完成通用功能。下面是个记录日志的例子。

Annotation
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AroundLog {
    String level() default "info";
}
Advice method
@Around("@annotation(aroundLog)") (1)
public Object AroundLog(ProceedingJoinPoint jp, AroundLog aroundLog (2)
            ) throws Throwable {
    try {
        log(logger, aroundLog.level(), "start " + jp );
        return jp.proceed(); (3)
    } finally {
        log(logger, aroundLog.level(), "finished " + jp );
    }
}
  1. 匹配带有annotation的方法

  2. 方法上的annotation类型是AroundLog

  3. 调用原方法

Call example
@RestController
@RequestMapping("/rest/")
public class TodoController {
    @AroundLog(level = "debug")
    @RequestMapping(value = "todoes/{id}", produces = MediaType.APPLICATION_JSON_VALUE )
    public Todo findOne(@PathVariable Long id){
        return todoRepository.findOne(id);
    }
}

上面的代码我们先创建了名为 AroundLog 的注解类型,然后通过Pointcut表达式匹配,并定义了该切面的行为,最后在业务代码中通过 @AroundLog(level = "debug") 调用。Spring完成类型增强后生成的新代码大致伪码如下

@RestController
@RequestMapping("/rest/")
public class TodoController$$FastClassBySpringCGLIB$$18a9e4f3 {
    final TodoController todoController

    @RequestMapping(value = "todoes/{id}", produces = MediaType.APPLICATION_JSON_VALUE )
    public Todo findOne(@PathVariable Long id){
        return AroundLog(() -> {
            todoController.findOne(id)
        }, aroundLog);
    }
}

3. spring mvc

早期Spring MVC是通过返回 ModelAndView 对象实现model和view的绑定。

@RequestMapping(value = "todo.html", produces = MediaType.TEXT_HTML_VALUE )
public ModelAndView todo_html(){
    return new ModelAndView("todo").addObject("todoList", todoRepository.findAll());
}

至于渲染层则可以通过xml配置灵活替换。

<!-- freemarker config -->
<bean id="freemarkerConfig" class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer">
    <property name="templateLoaderPath" value="/WEB-INF/freemarker/"/>
</bean>

<!--
View resolvers can also be configured with ResourceBundles or XML files. If you need
different view resolving based on Locale, you have to use the resource bundle resolver.
-->
<bean id="viewResolver" class="org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver">
    <property name="cache" value="true"/>
    <property name="prefix" value=""/>
    <property name="suffix" value=".ftl"/>
</bean>

现在后端更加服务化,通常只返回rest接口数据,我们可以使用 @RestController 类似2.3节的代码创建rest服务,spring会自动将Bean映射为json或xml。

4. spring websocket

Spring WebSocket提供了STOMP over WebSocket的能力,这使我们可以方便的开发一些简单的实时交互应用。

首先,启用STOMP over WebSocket:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS(); (1)
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/queue", "/topic");  (2)
        registry.setApplicationDestinationPrefixes("/app"); (3)
    }
}
  1. 注册WebSocket的endpoint,这里同时使用SockJS的通讯协议,当浏览器不支持WebSocket时可以fallback到Ajax/XHR或long polling。

  2. Stomp并不真的提供 queuetopic ,它使用 SENDSUBSCRIBE 语义操作“destination”,这里的 "/queue", "/topic" 都是destination前缀。参考 Stomp specification

  3. 应用初始化消息的前缀。

然后我们发送应用的初始化数据:

@SubscribeMapping("/todoes")  (1)
public Iterable<Todo> findAll(){
    return todoRepository.findAll();
}
  1. 标记findAll响应Subscribe消息,当客户端Subscribe "/app/todoes" 时,客户端会收到findAll的结果。

最后在"topic"上发送增量数据实现实时响应

@Around("(execution(* save(..)) || execution(* delete(..))) && target(repository)") (1)
public Object publishChange(ProceedingJoinPoint jp, CrudRepository repository) throws Throwable {
    logger.info("publishChange " + jp);
    List original = Lists.newArrayList(repository.findAll()); (2)
    Object ret = jp.proceed();
    List updated = Lists.newArrayList(repository.findAll());  (3)

    ObjectMapper mapper = new ObjectMapper();
    JsonNode patchNode = JsonDiff.asJson(mapper.valueToTree(original), mapper.valueToTree(updated));

    messaging.convertAndSend("/topic/todoes", patchNode);  (4)
    return ret;
}
  1. 拦截Repository的save和delete方法

  2. 获取方法执行前的数据

  3. 获取方法执行后的数据

  4. 发送patch数据

Note
UpdateAspect无法捕捉到数据的更新操作,因为CrudRepository更新数据的流程是先根据主键调用findOne找到当前Bean,对Bean设值,然后save。显然在save之前缓存已经更新了,所以通过拦截save方法无法获得数据的变化。
Tip
如果使用表达式 @Around("target(repository)") 是否会导致 findAll() 被切面拦截或递归拦截?

详细用法请参考 UpdateAspect 类和前端js脚本。

5. spring tx

Spring的声明式事务是Spring中最精彩的部分,它充分利用了Spring的容器、AOP和Servlet同步模型。JdbcTemplate是Spring直接操作jdbc的工具类,我们可以从该类入手观察整个Spring事务时如何工作的。追踪代码,获得大致流程如下:

  1. TransactionAspectSupport 完成切面拦截,事务从这里开始。

  2. 根据事务配置,Spring会返回对应的PlatformTransactionManager,例如原生jdbc就是DataSourceTransactionManager。

  3. TransactionManager开始工作前利用TransactionSynchronizationManager将所需资源绑定到当前线程。由于Servlet 3.0之前都是同步的,一次请求中的方法都是在同一线程中执行,TransactionSynchronizationManager大大简化了方法调用之间的参数传递。

  4. DataSourceUtils调用TransactionSynchronizationManager中绑定的资源获取Connection。

  5. 在Connection完成操作后,TransactionManager根据执行情况commit或rollback。

6. spring boot

由于Spring整体的配置较多,即使用注解仍有许多配置项,而一些常见项目与Spring的集成配置基本是通用的,于是Spring将这些项目预集成,通过检测classpath中是否有对应的类来开启配置,这就是Spring Boot项目。在 http://start.spring.io/ 可以通过勾选项目特性快速生成自己的项目配置,当然也可以在pom手动加入依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.3.7.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jetty</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <exclusions>
            <exclusion>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-tomcat</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-rest</artifactId>
    </dependency>
</dependencies>
  • spring-boot-starter: 引入Spring core,并实现spring boot的自动配置。

  • spring-boot-starter-web: 启用Spring webmvc,并通过spring-boot-starter-tomcat内嵌tomcat。

  • spring-boot-starter-jetty: 使用内嵌的Jetty。

  • spring-boot-starter-websocket: 跟Jetty,Tomcat,Undertow,WebLogic,WebSphere等常见容器的WebSocket适配器。

  • spring-boot-starter-freemarker: 跟Freemarker的集成。

  • spring-boot-starter-data-jpa: 启用JPA,通过Hibernate实现。

  • spring-boot-starter-data-rest: 启用spring-data-rest-webmvc,实现Data model到rest接口的自动暴露。

  • spring-boot-starter-jdbc: 启用原生jdbc。

预定义配置自然不能完全满足我们的要求,Spring boot使用 application.properties 作为全局配置文件,默认配置值可以在 http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html 找到。

Spring Boot还可以利用maven插件将项目打包成standalone jar文件,boot的Launcher会自动查找项目内含有main方法的class,然后执行。

Appendix A: Code Description

  • AppBoot.java

    项目入口

  • LogAspect.java

    日志切面例子,含before advice和around advice,还有针对方法和类的不同pointcut

  • UpdateAspect.java

    利用切面获取数据更新状态,然后使用WebSocket发送差异数据

  • DatabaseConfig.java

    数据源配置

  • WebSocketStompConfig.java

    WebSocket配置

  • TodoController.java

    Spring webmvc和websocket的Controller例子

  • resources/public

    Spring boot默认的静态文件目录

  • resouces/templates

    Spring boot默认的template文件目录,例子用的Freemarker

运行方法: 直接执行AppBoot或 mvn package 后用 java -jar 执行生成的jar包。

http://127.0.0.1:8080/ 是个todo list的例子(需要最新版的Chrome或Firefox),可以添加删除内容,如果开多个浏览器,数据会在多个窗口同步。

http://127.0.0.1:8080/profile/todoes 是alps格式的API描述