Spring 如何简化 Java 开发

因为不做 Web 后端的编程,我曾经以为我不需要了解 Spring。最近我在想,Spring 这么火,它究竟能做什么?对我的日常工作有帮助吗?然后就发现了新大陆。这篇文章,算是个序曲。

0 Spring 有啥用

Spring 最根本的使命:全方位简化 Java 开发

Spring 不仅仅局限于服务器端开发,任何 Java 应用都能在简单性、可测试性和松耦合等方面从 Spring 中获益。

为了达到这个目标,Spring 做了什么?

  • 基于 POJO 的轻量级和最小侵入性编程;
  • 通过依赖注入和面向接口实现松耦合;
  • 基于切面和惯例进行声明式编程;
  • 通过切面和模版减少样板式代码。

下面逐步展开。

1 POJO

简单老式 Java 对象(Plain Old Java object),简称 POJO。就是一个简单普通的 Java 类。如:

package com.pl.spring;
public class HelloWorldBean {
    public String sayHello() {
        return "Hello World";
    }
}

Spring 竭力避免因自身的 API 而弄乱应用代码。当使用 Spring 框架时,不需要修改 POJO(比如让它继承框架里的某个类,实现框架里的某个接口),直接就能把它变成一个组件。

Spring 的非侵入性编程模型,使得这个类在 Spring 应用和非 Spring 应用中,都可以发挥同样的作用。

POJO 就是一个个组件(Component / Bean)。组件之间怎么协作来完成特定功能?靠依赖注入(DI)。

2 依赖注入

在项目中应用 DI,会让代码变得异常简单,并且更容易理解和测试

2.1 依赖注入模式

现在先脱离 Spring,单纯讲依赖注入。依赖注入作为一种设计模式,重点体现了“依赖倒置原则”(依赖接口,不依赖实现)。

通过 DI,对象的依赖关系将由系统中负责协调各对象的第三方组件在创建对象的时候进行设定。对象无需自行创建或管理它们的依赖关系。

拿我们的日常工作举个例子:

使用依赖注入模式之前

package com.pl;

public class FineReportProgrammer implements Programmer {
    
    private FixCustomeBugTask task;
    
    FineReportProgrammer() {
        // 与 FixCustomBugTask 紧紧耦合
        this.task = new FixCustomBugTask();
    }
    
    @Override
    public void doTask() {
        this.task.process();
    }
}

FineReportProgrammer 自行管理对 FixCustomeBugTask 的依赖。可以看到,它俩耦合在了一起。如果要处理其他类型的任务,比如“改一般bug”,“做研究性任务”,“开发新功能”,等等,这个类都无法实现,只能再次修改这个类,违背了“开闭原则”(对扩展开放,对修改关闭)。

另外,我们也很难针对这个类写单元测试。

使用依赖注入模式之后

package com.pl;

public class FineReportProgrammer implements Programmer {

    private Task task;

    FineReportProgrammer(Task task) {
        this.task = task;
    }

    @Override
    public void doTask() {
        this.task.process();
    }
}

现在,FineReportProgrammer 只依赖一个 Task 接口,可以处理任意类型的任务。

因为依赖接口,单元测试也比较好写(接口很容易 Mock):

package com.pl;

import org.easymock.EasyMock;
import org.junit.Test;

public class FineReportProgrammerTest {

    @Test
    public void shouldProcessTask() {
        // 准备 mock 对象
        Task mockTask = EasyMock.mock(Task.class);
        mockTask.process();
        EasyMock.expectLastCall().times(1);
        EasyMock.replay(mockTask);

        // 执行测试
        FineReportProgrammer programmer = new FineReportProgrammer(mockTask);
        programmer.doTask();

        // 检验结果
        EasyMock.verify(mockTask);
    }
}

从单元测试里可以看到,POJO 组件写好了,还需要一个第三者,去组合这些组件,以完成特定功能。这段代码,就是将 mockTask 对象注入到 programmer 对象中:

FineReportProgrammer programmer = new FineReportProgrammer(mockTask);
programmer.doTask();

但是,在 Spring 中,不是这样显式地编码。

2.2 Spring 的装配

创建应用组件之间协作关系的行为,通常称为装配(wiring)。

使用 Spring 进行装配,分三步:

  1. 写配置文件,描述组件之间的依赖关系;
  2. 启动程序,Spring 把所有组件加载到容器中,并完成装配;
  3. 从容器中取对象,直接用。

Spring 里有三种装配方式:

  1. xml 配置
  2. Java Config
  3. 自动装配

前两种都是配置文件(第一种是 xml 描述,第二种是 Java 类描述),最后一种没有配置文件,但是要使用注解。

简单起见,这里只讲 Java Config。(在实践中,一般使用自动装配,让 Spring 自己根据注解,去发现组件,并推测依赖关系)

配置类:

package com.pl;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ProgrammerConfig {

    @Bean
    public Programmer programmer() {
        return new FineReportProgrammer(task());
    }
    
    @Bean
    public Task task() {
        return new FixCustomeBugTask();
    }
    
}

主类调用:

package com.pl;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ProgrammerMain {

    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ProgrammerConfig.class);
        Programmer programmer = context.getBean(Programmer.class);
        programmer.doTask();
    }
}

注意,所有的 Programmer 和 Task 都是 POJO。一个 Programmer 不知道它具体要做什么任务;一个任务也不知道它会被谁执行。这一切都是通过配置文件来控制的。类越简单,耦合越少,出 bug 的可能性就越少。

3 面向切面

面向切面编程(aspect-oriented programming, AOP)允许你把遍布应用各处的功能分离出来形成可重用的组件。

3.1 关注点分离

一个组件除了实现自身的业务逻辑之外,往往还要做一些额外工作,例如日志记录、功能点记录、事务管理、安全处理(例如对参数的过滤)等。这些额外的工作,是系统中很多组件都要去做的,因此被称为“横切关注点”。

横切关注点有什么问题?

  • 实现横切关注点功能的代码会重复出现在多个组件中,违背了 DRY 原则。即使封装成一个简单的方法调用,这个调用代码,也是重复的;
  • 组件需要关心与自身业务无关的代码,导致复杂性增加。

AOP 技术,就是想把这些“横切关注点”从组件的业务代码中分离出来,这样一来,组件会具有更高的内聚性,更加专注于自身的业务,不需要了解额外的东西。AOP 能够确保 POJO 的简单性。

3.2 一顿饭告诉你 AOP 是什么

主要讲一下 AOP 的思想。它是 Spring “控制反转”(IOC)思想的一个重要体现(另一个是 DI)。其实就是谁主动的问题。

比如我们去吃饭,一开始去一家服务态度很差的餐厅:

  • 我们走进去,没人理。然后就叫服务员,服务员带我们就坐;
  • 坐下之后又不理我们了,我们叫服务员拿菜单来;
  • 点完菜,我们叫服务员把菜单拿走;
  • 过了好久一直没上菜,我们又叫服务员,问他为什么不上菜,他说:“你们没叫我上菜啊”……

一气之下,我们改去海底捞:

  • 还没进门,服务员就迎上来,带我们就坐;
  • 马上拿出菜单,帮助我们点菜下单;
  • 菜做好了第一时间端过来;
  • 吃饭的过程中,时不时递过来干净的热毛巾,帮我们加汤、加水果……

我们在海底捞吃得很爽。因为我们只管吃,其他一概不操心。

AOP 就是这样,作为 POJO,只管实现自己的业务功能,其他一概不操心。“横切关注点”会自己找上门来,做它该做的事。

3.3 AOP 应用举例

假如我们公司每个研发都配了一个程序员鼓励师。做任务之前,鼓励师给你加油打气;任务完成之后,鼓励师给你鼓掌唱歌。

3.3.1 不使用 AOP

package com.pl;

/**
 * 程序员鼓励师
 * Created by plough on 2019/5/31.
 */
public class CheerGirl {

    public CheerGirl() {
    }

    public void cheerBeforeWork() {
        System.out.println("Come on! You can do it!");
    }

    public void cheerAfterWork() {
        System.out.println("Yes, you did it! I'll sing a song for you...");
    }
}
package com.pl;

/**
 * Created by plough on 2019/3/28.
 */
public class FineReportProgrammer implements Programmer {

    private Task task;
    private CheerGirl cheerGirl;

    FineReportProgrammer(Task task, CheerGirl cheerGirl) {
        this.task = task;
        this.cheerGirl = cheerGirl;
    }

    @Override
    public void doTask() {
        cheerGirl.cheerBeforeWork();
        this.task.process();
        cheerGirl.cheerAfterWork();
    }
}

问题有:

  • 这个过程很不自然。你是程序员鼓励师,应该你主动来鼓励我,而不是我每次都求着你鼓励吧?
  • 需要把鼓励师注入到 FineReportProgrammer 类中,一方面使代码复杂化了,另一方面,有的程序员是不需要鼓励师的,我们也强制注入了一个鼓励师。对于那些钢铁程序员,给他注入一个 null 吗?你就等着日后 NPE 大爆发吧。

我们试试切面的方案。

3.3.2 使用 AOP

先让程序员专心干活:

package com.pl;

/**
 * Created by plough on 2019/3/28.
 */
public class FineReportProgrammer implements Programmer {

    private Task task;

    FineReportProgrammer(Task task) {
        this.task = task;
    }

    @Override
    public void doTask() {
        this.task.process();
    }
}

然后把 CheerGirl 改造为切面:

package com.pl;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

/**
 * 程序员鼓励师
 * Created by plough on 2019/5/31.
 */
@Aspect
public class CheerGirl {
    @Pointcut("execution(** com.pl.Programmer.doTask(..))")
    public void doTask() {}

    @Before("doTask()")
    public void cheerBeforeWork() {
        System.out.println("Come on! You can do it!");
    }

    @After("doTask()")
    public void cheerAfterWork() {
        System.out.println("Yes, you did it! I'll sing a song for you...");
    }
}

然后再开启切面配置,让容器能加载到切面:

package com.pl;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

/**
 * Created by plough on 2019/3/28.
 */
@Configuration
@EnableAspectJAutoProxy
public class ProgrammerConfig {

    @Bean
    public Programmer programmer() {
        return new FineReportProgrammer(task());
    }

    @Bean
    public Task task() {
        return new FixCustomeBugTask();
    }

    @Bean
    public CheerGirl cheerGirl() {
        return new CheerGirl();
    }

}

这时,主要工作就完成了。来运行看看,主类代码不变:

package com.pl;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

/**
 * Created by plough on 2019/3/28.
 */
public class ProgrammerMain {

    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ProgrammerConfig.class);
        Programmer programmer = context.getBean(Programmer.class);
        programmer.doTask();
    }
}

日志如下:

...
16:40:18.906 [main] DEBUG org.springframework.aop.aspectj.annotation.ReflectiveAspectJAdvisorFactory - Found AspectJ method: public void com.pl.CheerGirl.cheerBeforeWork()
16:40:18.914 [main] DEBUG org.springframework.aop.aspectj.annotation.ReflectiveAspectJAdvisorFactory - Found AspectJ method: public void com.pl.CheerGirl.cheerAfterWork()
16:40:19.083 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'programmer'
16:40:19.101 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'task'
16:40:19.152 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'cheerGirl'
Come on! You can do it!
fix custom bug...
Yes, you did it! I'll sing a song for you...

Process finished with exit code 0

可以看到,程序员专心干活的时候,鼓励师认真完成了她的工作。

4 消除样板代码

我们可能会写很多与业务无关的样板代码。比如,使用 JDBC 查询数据库,需要编写大量与 JDBC 相关的代码,以及 catch、finally 的代码。

比如下面这段代码,目的是查询数据库,以获得员工姓名和薪水。

public Employee getEmployeeById(Long id) {
    Connection conn = null;
    PreparedStatement stmt = null;
    ResultSet rs = null;
    try {
        conn = dataSource.getConnection();
        stmt = conn.prepareStatement(
        "select id, firstname, lastname, salary from " +
             "employee where id=?");
        stmt.setLong(1, id);
        rs = stmt.executeQuery();
        Employee employee = null;
        if (rs.next()) {
            employee = new Employee();
            employee.setId(rs.getLong("id"));
            employee.setFirstName(rs.getString("firstName"));
            employee.setLastName(rs.getString("lastName"));
            employee.setSalary(rs.getBigDecimal("salary"));
        }
        return employee;
    } catch (SQLException e) {
    } finally {
        if (rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {}
        }
        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {}
        }
        if (conn != null) {
            try {
                conn.close();
            } catch (SQLException e) {}
        }
    }

    return null;
}

如果使用 Spring 的 JdbcTemplate 来重写 getEmployeeById 的话,就是这个样子的:

// JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);

public Employee getEmployeeById(Long id) {
    return jdbcTemplate.queryForObject(
            "select id, firstname, lastname, salary from " +
                "employee where id=?",
            new RowMapper<Employee>() {
                @Override
                public Employee mapRow(ResultSet rs, int rowNum) throws SQLException {
                    Employee employee = new Employee();
                    employee.setId(rs.getLong("id"));
                    employee.setFirstName(rs.getString("firstName"));
                    employee.setLastName(rs.getString("lastName"));
                    employee.setSalary(rs.getBigDecimal("salary"));
                    return employee;
                }
            },
            id);
}

可以看到,烦人的样板式代码全都被消除了。

除了 JDBC 之外,在许多编程场景中,都会出现类似的样板代码,例如 JMS、JNDI 和使用 REST 服务。Spring 通过模版封装来消除样板式代码。体现了模版方法模式的威力。

(注:本文主要整理自《Spring In Action (第四版)》第一章第一节)