0%

观察者模式

在说事件机制之前,我们先聊一下观察者模式,因为 Spring 的事件机制本质上是观察者模式的一种实现。

我们都知道,有一种设计模式叫观察者模式,它用于建立对象之间一对多的依赖关系,当一个对象的状态发生变化时, 所有依赖它的对象都会得到通知并自动更新。这种模式常用于需要实现对象之间松耦合的场景, 其中一个对象(被观察者)的状态变化会影响到其他多个对象(观察者)。

观察者模式的主要角色

  1. 被观察者(Subject):也称为主题或者可观察对象,它维护了一个观察者列表,可以添加、删除和通知观察者。当其状态发生变化时,会通知所有注册的观察者。
  2. 观察者(Observer):观察者是依赖于被观察者的对象,它定义了一个方法,用于在被观察者状态发生变化时进行更新操作。

观察者模式的工作流程

  1. 被观察者对象注册观察者:观察者通过某种方式向被观察者注册,通常是将自己添加到被观察者的观察者列表中。(建立起观察者与被观察者的关联)
  2. 被观察者状态变化
  3. 通知观察者:被观察者遍历观察者列表,调用每个观察者的更新方法(onEventhandle...)
  4. 观察者更新:每个观察者根据被观察者的通知进行相应的更新操作,执行与状态变化相关的任务

观察者模式的优点

解耦性:被观察者和观察者之间的关系是松耦合的,提高了代码的可维护性和扩展性。 可以轻松添加或删除观察者,可以在不修改被观察者的情况下增加新的观察者。

Spring 中的事件

Spring 的事件机制是 Spring 框架中的一个重要特性,基于观察者模式实现,它可以实现应用程序中的解耦,提高代码的可维护性和可扩展性。

Spring 的事件机制包括事件、事件发布、事件监听器等几个基本概念:

  1. 事件:事件是一个抽象的概念,它代表着应用程序中的某个动作或状态的发生。
  2. 事件发布:是事件发生的地方,它负责发布事件,从而通知事件监听器。
  3. 事件监听器:事件的接收者,它负责处理事件并执行相应的操作。

在 Spring 的事件机制中,事件源和事件监听器之间通过事件进行通信,从而实现了代码的解耦。

1

如上图所示,在观察者模式的实现中,往往还会有一个 Dispatcher 的角色, 由它来通知观察者,在 Spring 中,ApplicationContext 就扮演了这个角色。

如何定义事件

在 Spring 中,我们可以通过继承 ApplicationEvent 来自定义一个事件:

1
2
3
4
5
6
7
import org.springframework.context.ApplicationEvent;

public class MyEvent extends ApplicationEvent {
public MyEvent(Object source) {
super(source);
}
}

我们会发现,ApplicationEvent 有一个必选的参数 source,这个参数在实践中往往传递 this,也就是事件发生处的对象,这个参数不能为 null

如何监听事件?

在 Spring 中,监听事件的方式有两种:

  1. 实现 ApplicationListener,需要指定它要监听的事件类型
1
2
3
4
5
6
7
8
9
10
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

@Component
public class MyEventListener implements ApplicationListener<MyEvent> {
@Override
public void onApplicationEvent(MyEvent event) {
System.out.println("MyEventListener::onApplicationEvent");
}
}

注意:我们需要添加 @Component 注解以便 Spring 可以注册这个观察者。

  1. 使用 @EventListener 注解
1
2
3
4
5
6
7
8
9
10
11
import com.example.springeventdemo.event.MyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class SpringEventListener {
@EventListener
public void myEvent(MyEvent myEvent) {
System.out.println("my event.");
}
}

我们可以使用 @EventListener 注解在托管 bean 的任何方法上注册事件监听器。 需要监听的事件通过方法的参数来指定。

如何发布事件

我们可以使用 ApplicationEventPublisher 来发布一个事件,也就是通知所有的观察者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.example.springeventdemo.event.MyEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

@Component
public class MyService {
@Autowired
ApplicationEventPublisher applicationEventPublisher;

public void publish() {
applicationEventPublisher.publishEvent(new MyEvent(this));
}
}

也可以使用 ConfigurableApplicationContext,不过这个接口其实也是继承了 ApplicationEventPublisher 接口。

事件异步处理

有时候,我们的一些事件是可以异步处理的,比如注册成功之后给用户发送验证邮件, 注册成功我们就可以返回了,而发送验证邮件的这一步操作可以异步进行处理, 从而加快接口的响应速度。

在 Spring 中,我们可以使用 @Async 注解来将一个 EventListener 标记为异步处理的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncMyEventListener {
@EventListener
@Async
public void listen(MyEvent myEvent) {
try {
// 模拟耗时操作
Thread.sleep(1000);
// 请求结束之后才会输出下面这一行
System.out.println("AsyncMyEventListener::listen");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

但是,要使用 @Async 我们必须在我们的主程序类中加上 @EnableAsync 注解:

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync // 加上这个注解
public class SpringEventDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringEventDemoApplication.class, args);
}
}

使用 @Async 注解的监听器,会放到跟请求不同的线程中处理。

指定 EventListener 的处理条件

我们可以通过 EventListenercondition 属性来决定监听器是否需要执行:

使用 SpEL 表达式,当然我们也可以把判断写到方法体内。

1
2
3
4
5
// 在 myEvent 的 foo 属性等于 'bar' 的时候才会触发
@EventListener(condition = "#myEvent.foo == 'bar'")
public void myEvent1(MyEvent myEvent) {
System.out.println("my event: foo=bar");
}

使用 condition 而不是写到方法体中的原因是:

  1. 解耦和可配置性:通过将条件与事件监听器声明分离,你可以在不修改监听器代码的情况下更改条件。这使得在不同的环境或不同的配置下轻松切换监听器的行为成为可能。
  2. 动态切换行为:允许你根据应用程序或配置来动态决定是否触发事件监听器。这对于需要根据运行时条件来启动或禁用监听器的情况非常有用。
  3. 可测试性:可以为不同的条件编写单元测试,以确保条件的正确性。
  4. 统一管理:当有多个监听器时,将条件集中管理在 condition 属性中可以提高代码的可读性,因为你可以轻松查看每个监听器的条件而无需查看每个监听器的具体实现。

监听多个事件

我们可以通过 EventListenerclasses 属性来指定要监听的多个事件:

1
2
3
4
@EventListener(classes = {MyEvent.class, AnotherEvent.class})
public void myEvent2(Object event) {
System.out.println("myEvent2: " + event.getClass());
}

这个时候,我们的参数类型就需要修改一下了。

指定事件监听器的执行顺序

我们可以通过 @Order 注解来指定一个事件的不同监听器的执行顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;

@Component
public class SpringEventListener {
@Order(2)
@EventListener
public void myEvent2(MyEvent myEvent) {
// 后执行
System.out.println("my event. order = 2");
}

@Order(1)
@EventListener
public void myEvent1(MyEvent myEvent) {
// 先执行
System.out.println("my event. order = 1");
}
}

控制事件在事务提交前执行

有时候,我们会在代码中通过 @Transactional 来使用事务:

1
2
3
4
@Transactional(rollbackFor = RuntimeException.class)
public void saveFoo(Foo foo) {
fooRepository.save(foo);
}

假设我们在这个方法中有很多代码,然后其中穿插地发布了一些事件,但是我们希望这些事件在整个事务后才去触发监听器的处理逻辑, 这个时候我们就需要使用 @TransactionalEventListener 来注解我们的事件监听器,而不是使用 @EventListener

发布事件:

1
2
3
4
5
6
7
8
9
10
11
@Transactional(rollbackFor = RuntimeException.class)
public void saveFoo(Foo foo) {
// 发布了事件,但是事件处理器并不会马上处理,要等事务开始提交、结束提交的时候才会执行
// 所以我们会看到 "before save" 和 "after save" 输出在 "before commit" 之前
FooEvent event = new FooEvent(this);
eventPublisher.publishEvent(event);

System.out.println("before save");
fooRepository.save(foo);
System.out.println("after save");
}

事件监听器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class TransactionEventListener {
// 在事务提交前处理这个 FooEvent
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(FooEvent event) {
System.out.println("before commit: foo event.");
}

// 在事务提交后处理这个 FooEvent
// 如果事务回滚则不会处理这个 FooEvent。
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(FooEvent event) {
System.out.println("after commit: foo event.");
}
}

上面的代码会输出:

1
2
3
4
before save
after save
before commit: foo event.
after commit: foo event.

@TransactionalEventListener 为我们提供了一个 phase 参数,让我们可以控制事件监听器的执行时机,它有以下可选值:

  • TransactionPhase.BEFORE_COMMIT:事务提交前
  • TransactionPhase.AFTER_COMMIT:事务提交后
  • TransactionPhase.AFTER_ROLLBACK:事务回滚后
  • TransactionPhase.AFTER_COMPLETION:事务完成后

tips:@TransactionalEventListener 并不是给我们监听事务的,只是控制事件在事务提交过程中的某一时刻触发。

什么是动态代理?

在 Java 中,动态代理是一种代理模式的实现方式,它允许在运行时动态地创建代理类并动态地处理方法调用。 动态代理常用于解耦合、处理和其他对象交互相关的行为控制。

动态代理例子

下面是一个简单的Java动态代理示例,它演示了如何使用动态代理创建一个代理对象,以及如何在代理对象上调用方法并在方法调用前后执行一些操作:

首先,我们需要创建一个代理接口,定义一些方法:

1
2
3
4
// 定义代理接口
public interface MyInterface {
void doSomething();
}

同时,也定义一个 MyInterface 的实现类:

1
2
3
4
5
6
public class MyInterfaceImpl implements MyInterface {
@Override
public void doSomething() {
System.out.println("MyInterfaceImpl doSomething");
}
}

然后,创建一个实现 InvocationHandler 接口的处理器类,它将负责在代理对象上调用方法时执行额外的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class MyInvocationHandler implements InvocationHandler {
private final Object realObject;

public MyInvocationHandler(Object realObject) {
this.realObject = realObject;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Before invoking " + method.getName());

// 调用真实对象的方法
Object result = method.invoke(realObject, args);

System.out.println("After invoking " + method.getName());

return result;
}
}

接下来,我们可以在主程序中使用动态代理创建代理对象并调用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.lang.reflect.Proxy;

public class Main {
public static void main(String[] args) {
// 创建一个真实对象
MyInterface myInterface = new MyInterfaceImpl();

// 创建代理处理器
MyInvocationHandler handler = new MyInvocationHandler(myInterface);

// 创建代理对象
MyInterface proxy = (MyInterface) Proxy.newProxyInstance(
MyInterface.class.getClassLoader(),
new Class[]{MyInterface.class},
handler
);

// 调用代理对象的方法
proxy.doSomething();
}
}

在上述示例中,我们首先定义了一个代理接口 MyInterface ,然后创建了一个实现了 InvocationHandler 接口的处理器类 MyInvocationHandler, 该处理器负责在方法调用前后输出日志。最后,在主程序中,我们使用 Proxy.newProxyInstance 方法创建了代理对象,并通过代理对象调用了方法。在方法调用前后,会执行处理器中的操作。

以上代码会输出:

1
2
3
Before invoking doSomething
MyInterfaceImpl doSomething
After invoking doSomething

我们可以看到,虽然我们好像是在调用 MyInterface 接口的方法,但是实际上又不单纯是调用这个方法,还在调用这个方法前后做了一些事情。

我们可以通过下图来更加直观地了解上面这个动态代理的例子到底做了什么:

1

这个图体现了使用 Java 动态代理的一些关键:

  • 要使用 Java 中的 Proxy 来实现动态代理我们就需要定义 interface
  • 我们需要通过 InvocationHandler 来调用被代理对象的方法,在 InvocationHandlerinvoke 方法中,我们可以在调用被代理对象方法前后加一些自定义逻辑
  • 需要通过 Proxy.newProxyInstance 创建代理对象,这个代理对象实现了我们指定的接口(也就是第二个参数传递的 interface)。

这个示例演示了如何使用动态代理创建代理对象,并在方法调用前后执行自定义的操作。在实际应用中,你可以根据需要自定义处理器类,以执行不同的操作,如日志记录、性能监测、事务管理等。

动态代理的使用场景

动态代理在 Java 中是用得非常多的,使用动态代理我们可以在不侵入代码的情况下,为类增加或修改行为。常见的使用场景如下:

  1. RPC

在我们使用 Dubbo 的时候在我们服务端的类上加一个 @DubboService 注解就可以将一个类声明为可以被远程调用的服务了; 然后在客户端进行注入的时候,加上 @DubboReference 就可以将服务声明为一个远程服务了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 服务端
@DubboService(version = "${product.service.version}")
@Service
public class ProductServiceRpcImpl implements ProductService {
@Autowired
private IProductService productService;

@Override
public Sku test(Long id) {
return productService.test();
}
}

// 客户端
@Component
public class ProductClient {
@DubboReference(
version = "${product.service.version}",
cluster = "failfast",
timeout = 5000,
retries = 2,
proxy = "jdk" // 一般情况不需要加这个配置
)
private ProductService productService;

public void test() {
System.out.println(productService.test(1L));
}
}

在上面 Dubbo 客户端的代码中,我们在 @DubboReference 的注解中加了个 proxy = "jdk" 表示我们要使用 JDK 的动态代理,

但是我们需要知道的是,JDK 的动态代理性能上并不是最优的,所以 Dubbo 的默认代理方式不是 JDK 动态代理,上面指定 proxy 只是为了演示。

Dubbo 实现动态代理的方式有三种:bytebuddyjavassistjdk

我们可以通过下面代码验证一下上面的 ProductClient 是否真的使用了 JDK 的动态代理:

1
2
3
4
5
6
 public void test() {
// class com.sun.proxy.$Proxy123
System.out.println(productService.getClass());
// true
System.out.println(Proxy.isProxyClass(productService.getClass()));
}

从输出结果我们可以看到,productService 实际上就是 JDK 代理类的对象。

  1. Spring 容器

我们知道 Spring 容器可以帮助我们注入一些对象,而这些被注入的对象都是代理对象,直接 new 出来的对象跟 Spring 注入的对象是不一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@SpringBootApplication
@MapperScan("com.baiguiren.mapper")
@RestController
public class Main {
@Autowired
private PersonService personService;

public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}

@GetMapping("/test")
public void test() {
// class com.sun.proxy.$Proxy66 这是一个代理类
System.out.println(personService.getClass());

PersonService personService1 = new PersonServiceImpl();
// class com.baiguiren.service.Impl.PersonServiceImpl,这是我们定义的类
System.out.println(personService1.getClass());
}
}

从上面这个例子我们发现,Spring 注入的对象,它的 Class 并不是我们所定义的那个类,而是一个代理类。

我的 aop 配置是 spring.aop.proxy-target-class=false,这样 Spring 会使用 JDK 代理,当然我们也可以去掉这个配置或者配置为 false,这样 Spring 会使用 CGLIB 来实现动态代理。

在使用 CGLIB 动态代理的时候,上面的 personService.getClass() 会输出 class (...).PersonServiceImpl$$EnhancerBySpringCGLIB$$23b7767d, 我们可以在输出中看到 $$EnhancerBySpringCGLIB$$,这就意味着我们拿到的注入的对象实际上是被 Spring 容器增强过的实例。

同样的,在我们使用 mybatisMapper 的时候,它也是一个代理:

1
2
3
4
5
6
7
8
9
10
11
@Service
public class PersonServiceImpl implements PersonService {
@Autowired
private PersonMapper personMapper;

@Override
public Person find(int id) {
System.out.println(personMapper instanceof Proxy); // true
return personMapper.selectPersonById(id);
}
}

所以虽然我们定义了一个接口,里面什么内容也没写,但实际上底层会帮我们去实现一个类,然后基于这个类来创建实例。

  1. 事务管理

事实上,这也可以归到 2 中去,当然我们也可以在不使用 Spring 的情况下通过动态代理来实现事务管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
@Slf4j
public class FooServiceImpl implements FooService {
@Autowired
private JdbcTemplate jdbcTemplate;

@Override
@Transactional(rollbackFor = RollbackException.class) // 事务管理
public void insertThenRollback() throws RollbackException {
jdbcTemplate.execute("INSERT INTO FOO (BAR) VALUES ('BBB')");
throw new RollbackException();
}
}

这个例子中的 Transactional 其实也是 Spring 给我们提供的功能。通过 Transactional 注解,我们的 FooServiceImpl::insertThenRollback 会得到增强。

在我们抛出 RollbackException 的时候,这个方法中执行的操作将会被回滚,但是我们不需要手动去执行开启事务、提交事务、回滚事务的操作,比较方便。

上面的 insertThenRollback 转换为伪代码如下:

1
2
3
4
5
6
7
try {
beginTransaction();
jdbcTemplate.execute("INSERT INTO FOO (BAR) VALUES ('BBB')");
commit();
} catch (RollbackException e) {
rollback();
}

通过上面这几个场景,相信我们已经体会到了动态代理的强大之处了, 当然实际中的使用远不止上面这几种场景。

代理模式

我们在文章开头就说了,动态代理是一种代理模式的实现方式。

现在我们再来看看代理模式的定义:

在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。

一个很简单的例子如下,我们需要针对控制器中的所有方法做性能监测:

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("/test1")
public void test1() throws InterruptedException {
long start = System.currentTimeMillis();

// 实际业务逻辑处理
System.out.println("test1...");
// 模拟耗时长的操作
Thread.sleep(100);

long end = System.currentTimeMillis();

log.info("GET /test1: " + (end - start)); // 输出日志
}

我们可以看到,上面这个例子实现的方式其实比较笨拙,如果我们有 100 个接口,上面的代码就得重复 100 遍,这种实现方式对开发人员是非常不友好的。

使用动态代理,我们可以有更加优雅的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 定义接口
public interface ITestController {
void test1();
}

// 定义实现类(被代理类)
public class TestController implements ITestController {
@Override
public void test1() {
System.out.println("test1...");
}
}

// 定义 InvocationHandler
@Slf4j
public class ControllerInvocationHandler implements InvocationHandler {
private final ITestController testController;

public ControllerInvocationHandler(ITestController testController) {
this.testController = testController;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.currentTimeMillis();

// 调用被代理类的方法
Object result =method.invoke(testController, args);

long end = System.currentTimeMillis();
log.info(method.getName() + " invoked: " + (end - start)); // 输出日志

return result;
}
}

// 创建代理对象
ITestController testController = new TestController();
// 代理对象,它实现了所有的 testController.getClass().getInterfaces()
ITestController testControllerProxy = (ITestController) Proxy.newProxyInstance(
testController.getClass().getClassLoader(),
testController.getClass().getInterfaces(),
new ControllerInvocationHandler(testController)
);

// 调用代理对象的方法
testControllerProxy.test1();

当然,这也只是个例子,如果我们使用 Spring,我们可以通过 aspect 来实现上面这个功能。

同时,我们也发现,使用了动态代理之后,我们就不必在每一个方法中加上那些性能监测的代码了,这就可以将开发人员从繁琐的操作中解放出来了。

动态代理实现原理

了解了动态代理的基本使用、常用的使用场景之后,让我们再来了解一下它的实现原理(基于 JDK 1.8)。

还是以上一小节的例子来说明,在上面的例子中,我们为 ITestController 接口升成了一个代理对象,但是这个代理对象为什么可以直接调用被代理对象的方法呢?

这是因为,底层实际上是创建另一个继承自 java.lang.reflect.Proxy 的代理类,这个代理类实现了我们指定的 interfaces,而其中的方法体中,则会去调用实际被代理对象的方法。

这么说有点抽象,其实我们可以通过 Java 的一个命令行参数来让 Java 帮我们将升成的代理类保存到文件上:

1
2
// 注意:是 Java 8,新版本可能会有一些差异
-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true

对于上一个例子中的 ITestController 的代理类,我们可以看到它的内容实际如下(项目目录下的 com/sun/proxy 文件夹):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.sun.proxy;

// ...
// $Proxy69 继承了 java.lang.reflect.Proxy 这个类,
// 实现了 ITestController 接口

public final class $Proxy69 extends Proxy implements ITestController {
private static Method m3;
// ... 省略其他代码

public $Proxy69(InvocationHandler var1) throws {
super(var1);
}

public final void test1() throws {
try {
// 实际上调用了 InvocationHandler 的 invoke 方法
// this 也就是代理对象
// m3 是实际接口的 reflect.Method
// 实际调用的时候,没有传递任何参数
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

// ... 省略其他代码
static {
try {
// 也就是 ITestController 中 test1 的反射 Method
m3 = Class.forName("com.baiguiren.ITestController").getMethod("test1");
} ...
}
}

从生成的代码中,我们可以看到,所有对代理对象的方法调用,都委托给了 InvocationHandler::invoke 方法。

生成代理类的过程可以用下图表示(Proxy 类中的实现):

2
  1. Proxy 类中有两个方法会创建代理类,一个是 getProxyClass,另一个是 newProxyInstancegetProxyClass 获取 Class 对象,而 newProxyInstance 会基于这个代理类创建一个代理对象。
  2. 上一步的两个方法中都会调用 getProxyClass0 来获取 Class 对象,这个方法会有缓存,如果之前已经创建则会从缓存获取,否则会新建一个 Class
  3. 如果是第一次创建代理类,则会通过 ProxyClassFactory 来创建一个新的 Class
  4. 在实际加载到内存之前,会通过 ProxyGeneratorgenerateProxyClass 方法来生成 Class 对象的字节码
  5. 通过 defineClass0 方法来创建一个 Class 对象。(这是一个 native 方法,具体实现由 JVM 提供)。

拿到了代理类,也就是上面那一段 $Proxy69 类所表示的类之后,就可以创建代理对象了。

因为这个代理类实现了我们指定的接口,所以是可以通过类型转换转换成不同的 interface 的,如 (ITestController) Proxy.newProxyInstance(...)

最终,我们就拿到了一个实现了 ITestController 接口的代理对象,它的行为跟我们原来的对象完全一致,通过这种方式我们获得了一个附加了额外功能的代理对象。

其他实现动态代理的方式

除了使用 JDK 的动态代理之外,还有一种实现动态代理的方式是 CGLIB 动态代理,上面也有提及,他们都有一些局限性,需要根据我们的实际应用场景来选择。

比如,JDK 动态代理需要我们先定义 interface,而 CGLIB 是通过继承的方式来实现动态代理的,如果某个类有 final 标记,那它是无法使用 CGLIB 来做动态代理的。

本文主要是为了介绍动态代理的一些原理,至于其他代理方式,都是殊途同归的,所以本文不做过多探讨了。

总结

动态代理是一种 Java 编程技巧,通过在运行时生成代理类,它能够拦截对原始类的方法调用并在方法执行前后执行自定义操作, 这一特性使其广泛应用于解耦、性能监测、事务管理、RPC、AOP 等各种场景,提高了代码的可维护性和可扩展性。

最后,再简单总结一下本文的内容:

  • 动态代理是一种代理模式的实现方式,它允许在运行时动态地创建代理类并动态地处理方法调用。
  • 动态代理被广泛应用于 Spring 中,我们通过 @Autowired 注入的对象就是一个代理对象。
  • 动态代理还被用于 RPC、事务管理等,可以将非业务代码放到代理层去实现
  • JDK 的动态代理是在运行时才根据 ClassLoaderinterface 来创建 Class 的,具体加载到 JVM 的操作是底层帮我们实现的
  • 还有一种很常见的动态代理方式是 CGLIB 动态代理,但它不能代理 final 类。
  • 而使用 JDK 动态代理则需要先定义 interface,代理类会实现这些 interface,但是实际上还是会调用被代理对象的方法(通过 Invocationhandler)。

今天早上看到一篇文章《笨功夫是普通人最后的依靠》,有感而发,文中说的内容都是自己现在的一些想法, 本想在下面评论一下,但是好像要说的太多了,评论写不下,也就有了本文。

背单词是学英语的 “笨功夫”

故事还得从差不多十几年前的初中说起,在我上小学的时候,所在的小学是还没有教英语的,所以我的英语是从初中开始学的。 还好初中上英语课的时候,老师依然是从 26 个英语字母教起的,只是会有种错觉,那些小学学过英语的同学好像不用怎么学就能在英语上获得好成绩。 但这种想法不太客观,因为在有这种想法的时候,潜意识中将他们小学几年的积累忽略了,所以多少会有点沮丧。

九边的文章中,提到了学习英语得先背单词,背单词就是学习英语中的 “笨功夫”。对于这点深有体会,虽然我的英语是初中开始学的, 而且我对语法可以说知之甚少,但是我在背单词上可是花了不少时间的,所以英语成绩也不至于太难看。 后来上了大学,也能凭借初高中的英语单词的积累,考过了四级。 然后做为一名程序员,日常开发的时候需要看到英文文档也可以比较流畅,当然肯定不如看中文顺畅。 说这些,并不是觉得这是什么光荣的事,只是想表达比较认可背单词是学习英语的 “笨功夫” 这一观点。

笨功夫之外,方法也重要

几年前看极客时间的《数据结构与算法之美》这门课的时候,提到了一点:

数据结构和算法就是一个非常难啃的硬骨头,可以说是计算机学科中最难学的学科之一了。 我当时学习也费了老大的劲,能做到讲给你听,我靠的也是十年如一的积累和坚持。如果没有基础、或者基础不好,你怎能期望看 2 个小时就能完全掌握呢?

九边的文章中也提到了自己的经历,想学写代码,然后一推人推荐学习《算法导论》。 对于这个,我个人也是深受其害,十几年前我一直徘徊在玩游戏和学习之间,常常觉得自己的时间不应该全部还在玩游戏上,怎么也得学习一下。 然后我就会去学习,我怎么学习呢?也是跟九边一样,找那些前辈们推荐的经典教材,比如算法、操作系统、编译原理、计算机网络相关的经典书籍,

依然记得我在高三的时候就买来了一本《编译原理》,也就是那本 “龙书”(因为封面是一条龙)。 但是,这本编译原理就算让现在的我去看估计也很难看懂,所以在学习方面,个人其实走了很多弯路,跌了不少跟头。 因为学习的方法不对,这种学习自然是持续不下去的,在这种学习过程中,需要耗费大量的心力,然后自我怀疑,最后放弃。

对于这一点,九边的文章中也提到了背后的根本原因:

  • 选错教材了,你拿得太难了,不适合你;
  • 投入时间不足。

这两点都是我当时存在的问题,一上来就选择了最难的教材来看,没有考虑到自身实力能不能看得懂。 当然,选择难的教材也不是不行,可能得投入足够的时间去把教材看懂,前提是,有途径可以解决学习过程中的遇到的问题, 比如遇到问题可以问问前辈,又或者像现在的 GPT,如果想借助百度这种东西可能大概率要失望。 跳过少数问题可能不影响学习的效果,但是如果绝大部分问题都没有能找到答案的方法,那换一本简单点的教材先学学基础是个更好的方法。

当然,说这些并不是为了鼓励大家去学习数据结构算法,只是想说,在我们学习遇到困难的时候,可能得考虑一下是不是上面两个原因导致的。

大脑对熟悉的东西更感兴趣

这句话已经不记得是从哪里看到的了,但是觉得颇有道理。 有时候,我们对某件事情不感兴趣是因为不擅长,而不是因为真的不擅长。 在进入一个相对新的领域的时候,我们会接触到很多新的名词、术语,会觉得特别费劲。 这个时候我们可能就会选择放弃了,当然,我们也可以选择坚持下去, 这些年我学到的一个非常重要的学习方法就是,对于新接触的东西,去多看、多想、多实践几遍,当然,这里说的是学习编程领域的东西。 很多东西,在我们这样几轮学习下来,好像其实也没有太难的东西,当然也有可能我学习的东西本身没有很难的东西。 但不得不承认,就算是那些你觉得不难的东西,很多人也潜意识中会觉得自己学不会, 但实际上只是他们投入的时间还不够多。

在开始的时候,我也会觉得枯燥无味,但是在熟悉多几遍之后,发现好像还挺有意思,尤其是使用新学到的东西解决了自己实际遇到的一些问题之后。 学习的过程大多如此吧,说到这,想起了几天前看到的《如何取得杰出成就》中提到的一点:

有一些工作,我们可能必须在自己讨厌的事情上努力工作数年,才能接近喜欢的部分,但这不是杰出成就产生的方式, 杰出的成就是通过持续关注自己真正感兴趣的事情来实现的 —— 当我们停下来盘点时,会惊讶于自己已经走了多远。

这篇文章是《黑客与画家》作者博客《How to Do Great Work》的翻译版,有兴趣可以看看,感觉还不赖。 在实际工作中,我们遇到的很多问题其实并不需要坚持数年才能解决,又不是研究什么前沿领域的东西, 但是对于一些难题需要花几天或者一两个星期去解决这种可能性还是很大的。 在这个过程我们会对问题域中的东西越来越熟悉,直到最后它们不再是我们的障碍。

问题是可以分解的

搜狐 CEO 张朝阳这几年在 B 站上更新了很多物理的教程,当然我是全都看不懂,只是想起他说过的一段话:

很多东西的话,就是你看起来很复杂,是因为你不熟悉,其实这个知识, 天下的这个知识和所有的东西,其实都是不难的,你把所有的再复杂的东西,把它分解成每一步的话, 他的基本的这个思维过程的,跟你早上吃什么饭,怎么做饭,怎么打车怎么点东西,都是一样的思维过程。 很多东西你理解不了,不是因为你笨或者是你不够聪明,而是因为你,你自己认为你理解不了是吧, 很多可能因为过去的经历啊,就是在课堂上这个回答不了问题啊,一些挫败的经历,考试的失败导致, 你就有一种恐惧,是一种恐惧和你的认为理解不了导致你理解不了。

虽然道理是这么个道理,但是不代表物理都没学过的人能看得懂他讲的物理课, 因为问题虽然可以分解,但是一个需要有深厚基础的问题恐怕你连分解都不知道怎么分解,更不要提解决。 就好像上文提到的《算法导论》这本书,里面有大量的数学推导,很多人看不懂是因为, 从作者的角度来说,他已经把问题分解为了若干个小问题,在他看来,看这本书的读者应该是已经掌握了他分解之后的问题的相关知识。 从推荐看这本书的人来看,他推荐的对象应该也掌握了书中那些分解之后的知识。 但是实际是,可能也有很多人没有考虑到自身实力,然后就去啃这些大部头,自然而然地看不懂。

很多时候我们遇到的问题都能找到恰当的分解方法,尤其是编程领域,要不然我们不大可能会碰到那个问题。 在摸爬滚打多年之后,我们会发现,很多那些入行时觉得困难的点最后都不是问题了, 这是因为,常见的问题我们基本都解决过一遍了,以致于我们再遇到同样的问题之后,就能马上想到应该怎么去做,就已经在心中有一二三四几个步骤了。 举一个例子,在学习做 web 应用的时候,其实很多东西都不懂,但是现在已经很清楚一个 web 应用大概应该是长什么样子的了:

  • 从浏览器发起的请求到达 web 应用之后,我们需要确定具体执行什么逻辑,因此需要有 “路由” 来将请求拍发给一个具体的方法,也就是某个 Controller 里面的一个方法。
  • 在请求的处理逻辑里面,我们可能需要去查询数据库,所有常用的 web 框架都提供了关于数据库查询的一些抽象,直接调用封装的那些方法即可。
  • 在返回的时候,我们要返回给客户端的实质上是纯文本的东西(HTTP 协议),但是 HTTP 相关的功能往往由 HTTP 服务器来处理的,比如 nginx
  • nginx 处理 HTTP 相关的东西,比如反向代理的 upstream 返回的数据长度有多长,需要算出来,将这个长度写入到 HTTP 头中,这样浏览器收到 HTTP 报文的时候才能准确地解析出一个 HTTP 报文包

弄清楚这些问题之后,不管换哪一种语言,我们都可以拿来实现一个 web 应用,无非就是解析 HTTP 报文,在代码里面做一些业务逻辑处理,然后返回一串 HTTP 报文。 而这里提到的,其实就是针对 web 应用开发中的几个大问题的分解,这些问题对于写了几年 web 开发的人来说其实都不是问题了。

再举一个例子,对于程序员来说,我们往往需要持续地学习,当我们去学习一些新的编程语言的时候,我们可以去思考一下:对于编程语言来说,它可以分解为哪些问题? 个人感觉,这个问题其实挺有价值。要回答这个问题,我们可以回到没有今天这些高级编程语言的时候,那些计算机领域的先驱们是怎么让计算机工作起来的。 我们会发现,其实一开始他们是用的 0 和 1 来去写指令的,后面进化到汇编语言,毕竟一堆 0 和 1 谁记得住? 有了汇编,去做一些操作就简单多了,比如做加法,用一个 ADD 指令就可以了。 但是有了汇编之后,还有一个问题是,不管是从开发、维护上来说,都需要对 CPU 有非常清楚的了解,比如需要了解 CPU 有哪些寄存器之类的知识, 也就是说,使用汇编依然需要了解机器本身的很多运作机制,这无疑是一个比较高的门槛。 再后来到 C 语言的出现,我们就不需要了解 CPU 是怎么工作也可以写出能解决问题的代码了。 但是 C 语言依然有一个毕竟严重的问题,那就是需要开发者手动去申请内存,使用之后再释放内存,如果程序员忘记释放,那么就会造成内存的泄露。 所以更高级的一些语言就有了 GC,也就是说,由语言底层的运行时去帮程序员回收那些已经不再使用的对象。

扯得有点远了,回到问题本身,对于编程语言来说,它可以分解为哪些问题? 这个问题其实非常简单,只要我们随便找一门编程语言的教程来看它们的目录就会知道,一门编程语言本身包含了:

  • 一些基础语法:如代码组织结构是怎样的。Java 是通过一个个的类来组织的,Go 是通过结构体来建立抽象然后通过函数来进行组织的。
  • 对于面向对象的语言来说:不同的编程语言会有不同的类的编写方式。
  • 基本的变量定义是如何定义的
  • 关键字有哪些,比如非常常见的 classpublicdef 之类的
  • 如何实现循环结构
  • 如何实现条件判断
  • 如何在方法中返回值。有些语言需要使用 return,也有些语言比较省事,方法内的最后一行会被当做返回值,比如 ruby
  • 一些常用的数据结构是如何封装的。比如数组、map
  • 标准库。比如如何执行一个系统命令这种功能。
  • 其他...

这个清单不太完整,但是也足够了,因为编程语言的相似性,我们在熟悉了某一门编程语言之后,往往也会比较容易学会另一门编程语言。 但是这也并不代表,我们可以使用这门新的编程语言去解决一些实际的问题,除非,在此之前,我们已经使用了其他编程语言解决过相同的问题了。 比如,我们从 PHP 转换到 Go,我们在 PHP 中已经解决过很多数据库查询的问题了,切换到 Go 中,对于数据库查询的这一问题,我们可以作如下分解:

  • 找到 Go 中查询数据库相关的库
  • 调用库函数来建立到数据库的连接
  • 调用库函数来发送一个 SQL 查询语句到数据库服务器,然后等待数据库服务器返回查询结果
  • 取得查询结果,做其他处理

清楚了我们需要解决的问题之后,其实我们真正要解决的重要问题是如何组织我们的代码,从而使得我们针对这个问题的解决方案更好维护、能更好地使用。 所以现在在学习的时候,更喜欢从实际的问题出发(毕竟计算机领域其实是更偏向于实践)。 然后根据自己拆分后的问题去找解决方案,事实证明,这样效率更高。 如果我们从技术本身出发,我们可能无法知悉这个技术为什么是今天这个样子的,在这种学习方式之下, 我们新学习的东西无法跟我们脑子里原有的东西建立起连接,最终只会很快就忘记。 但是如果从我们熟悉的问题出发,去寻找一种新的解决方案的时候,其实新的知识跟自己的经验是可以很好的联系起来的,这样我们通过一个问题就能联系到不同的解决方案。

真的扯远了,说回正题。说这么多其实就是想说,碰到难题的时候我们也不能盲目地花笨功夫, 遇到难题的时候,我们也许可以考虑一下,这个问题可以如何分解,然后如何解决分解之后的问题。 如果分解后的问题是我们可以解决的,那我们的 “笨功夫” 那就是使用对了。

学习是为了找到学习方法

再说一个关于 “笨功夫” 的个人经历,还是初中的时候,在初中的时候花了很多时间在学习上,但是学习效果并不是非常明显, 多年以后,才明白,自己当初的那种学习其实是 “死学”,也就是不讲究方法的学习,免不了学了就忘。 初中的时候一个物理老师跟我们说他学生时代,有一天在思考问题很久之后突然 “开窍” 了, 以前没懂,现在知道了他说的 “开窍” 大概是找到了关于学习的套路。 可惜的是,我在读书的那十几年里,并没有经历过这样的 “开窍”,所以成绩一直平平无奇。

直到自己工作以后,因为自己从小到大是那种不太擅长交流的人,所以工作前几年遇到问题的时候也基本不会去请教别人, 那怎么办呢?那就自己想办法去解决各种技术问题呗,然后几年下来,好像自己的学习能力有所提升了,明显的表现是,学习新东西的时候会学习得更快了。 后面才懂,越来保持学习其实不只是为了学到各种解决问题的方法,实际上有很多东西都是学了之后用不上的,更重要的是在这个过程中学会如何学习。 关于这一点,陈皓有过一个经典的陈述。

学习不仅仅是为了找到答案,更是为了找到方法 - 陈皓

你有没有发现,在知识的领域也有阶层之分,那些长期在底层知识阶层的人,需要等着高层的人来喂养, 他们长期陷于各种谣言和不准确的信息环境中,于是就导致错误或幼稚的认知, 并习惯于那些不费劲儿的轻度学习方式,从而一点点地丧失了深度学习的独立思考能力,从而再也没有能力打破知识阶层的限制,被困在认知底层翻不了身。

可见深度学习十分重要,但应该怎样进行深度学习呢?下面有三个步骤:

  1. 知识采集。 信息源是非常重要的,获取信息源头、破解表面信息的内在本质、多方数据印证,是这个步骤的关键。
  2. 知识缝合。 所谓缝合就是把信息组织起来,成为结构体的知识。这里,连接记忆,逻辑推理,知识梳理是很重要的三部分。
  3. 技能转换。 通过举一反三、实践和练习,以及传授教导,把知识转化成自己的技能。这种技能可以让你进入更高的阶层。

这就好像,你要去登一座山,一种方法是通过别人修好的路爬上去,一种是通过自己的技能找到路(或是自己修一条路)爬上去。 也就是说,需要有路才爬得上山的人,和没有路能造路的人相比,后者的能力就会比前者大得多得多。 所以,学习是为了找到通往答案的路径和方法,是为了拥有无师自通的能力。

把时间当作朋友

这个标题来源于李笑来的《把时间当作朋友》这本书,书买了我还没看,但是看过他在得到的课程上这一话题的相关文章。

今天这个社会变得越来越浮躁,我们难免会受到影响,经常会想着今天做一件事,明天就能看到成果。 但实际上,在竞争激烈的今天,聪明人很多,又聪明又努力的也有很多,我们能做的只是接受这个事实, 然后持续在自己所在的领域花多一点 “笨功夫”,把时间当作朋友,就算最终我们没有实现最初的目标, 但是回头再看的时候,会发现原来自己已经走得很远了。

最后,用吴军《格局》中的一句话来结束本文:

事实上,功夫没下够,用什么方法都是在浪费时间。

本文旨在给大家一点关于如何写单元测试的指南,如果能帮助到大家的话那最好不过了。

前言

写代码也有几年了,可能很多人都只是知道有单元测试这个东西,但是自己从来没有写过单元测试。 单元测试好像从来都只是一个可选项,而不是必选项,因为就算没有单元测试,每个公司起码也还有专门的测试人员, 我们写好代码,然后放到测试环境交给测试人员去验证即可。这样看来好像没有单元测试也可以。

但是在走过不少弯路之后发现,即使我们没有办法做到 100% 的单元测试覆盖率,仅仅对一些复杂的功能写上单元测试,也还是可以节省我们大量的时间。 主要原因是如下:

  • 在写单元测试的过程中,就可以发现自己写的代码存在的一些 bug,在部署到测试环境之前,我们已经验证过一遍了。到达测试人员那一环节的 bug 更少了。
  • 存在单元测试的时候,在修复 bug 或者后续增加或者修改功能的时候,就可以在写完代码之后立即验证是否对旧的模块有影响。
  • 单元测试驱动可以驱动我们去写出更好的代码,因为你把代码写得随意一点就会发现这怎么测啊,我自己都看不懂自己写的代码。
  • 使用单元测试,可以让我们的验证更省时间。我们不必把应用运行起来,然后利用现有系统去造数据什么的来验证我们修改的代码。我们只需要把跟当前无关的代码 mock 掉就可以只验证当前在写的代码了。

什么是单元测试

可能也有一部分人会写测试,但是可能写得并不好,比如测试的粒度太大,比如直接针对 http 接口写测试, 但是我们也知道,一个接口背后包含的逻辑可能非常多,这样一来,我们写的测试包含的不确定性也会非常大, 因为这个大接口背后任何一个逻辑的修改都有可能导致我们的测试不通过,这样的 “单元测试” 无疑是非常脆弱的。 如下图这样,RPC 服务端、数据库服务器、文件系统、HTTP 服务端的异常都会导致我们的这种测试失败。 如果我们现在也在写这样的测试,那我们就得好好看看接下来的内容了。

本质上来说,这种测试不是单元测试,而是一种集成测试,单元测试是不包含跟其他组件的交互的,而我们的 http 接口可能会调用数据库,对数据库有强依赖。 这种依赖性也是测试脆弱性的一个来源,在良好的单元测试中,所有依赖都是被 mock(模拟) 掉的,也就是说, 我们的代码中,依然会有数据库访问的代码,但是在运行测试的时候,并不会产生实际的数据库访问操作。 在一个复杂的系统中,可能还会包含 rpc 调用、http 调用等,而这些强依赖性的东西我们都是需要在单元测试中 mock 掉的。

要认识单元测试,首先要明白什么是 “单元”。所谓 “单元” 指的是代码调用的最小单位,实际上指的就是一个功能块(Function)或者方法(Method)。 单元测试指的就是对这些代码调用单元的测试。单元测试是一种白盒测试,就是必须要对单元的代码细节很清楚才能做的测试。 所以说,如果我们代码没写好,测试也没法写,写单元测试可以驱使我们写出更好的代码。

单元测试的编写和执行都是由软件工程师来做的。相对于单元测试,还有集成测试。 集成测试基本都是都是黑盒测试,主要由测试人员根据软件的功能手册来测试,需要有专门的测试环境配合。

单元测试的价值

单从测试的角度来说,单元测试的成本是最低的,速度最快的。 因为单元测试没有任何对外部的依赖,写完直接就可以执行,我们不需要为单元测试准备一整套环境,比如先把服务器各种组件安装好,运行起来,然后把应用启动。

在我们写完代码的时候,点一下运行测试,马上就可以知道我们的代码是否有问题。 因为单元测试不需要依赖这些外部的东西。所以就算我们连服务器都还没准备好,我们也可以对我们的代码进行单元测试来验证代码的正确性。

除此以外,对于软件工程师来说,如果写代码时对自己的代码没有办法快速的验证,也就没有一个反馈,往往会有一种强烈的不安全感。 写的代码越多,不安全感累积的会越多,最后会发觉自己对自己所写的代码完全没有把握。 即使是快速的迭代方式,最少也要一周才能得到测试的反馈。并且很有可能测试的反馈结果会导致自己一周的代码都白写了,全部要推翻重来。 所以测试人员在测试的时候,软件工程师非常焦虑。如果迭代时间更长的话,造成的心理压力会更大。 测试在进行的时候,软件工程师往往会疲于奔命地去修复问题,也容易和测试团队发生冲突,从而产生沟通问题。

当然,这个问题会在持续一段时间后会好转,因为 bug 总会随着时间的推移被一个个修复。 然后我们就可以在一个更加稳定的系统上进行一些新功能的开发。 但是依然无法避免,新开发的功能可能会在某些非常隐秘的地方破坏了旧的逻辑,然后在一段时间后才能发现。 这可能不是我们想看到的结果。

另外,单元测试一旦写好可以长期使用,特别是在回归的时候,可以帮助节省大量的测试时间, 我们可以很容易知道,新的功能、或者对旧代码修改有没有对原有功能有破坏,单元测试可以帮忙发现很多隐藏的问题。

总的来说,单元测试可以给我们带来如下价值:

  • 成本更低,验证速度更快。(不依赖任何实际环境)
  • 减少回归测试的时间。(单元测试可以确保旧的功能没有受到影响)
  • 驱动我们写出更好的代码,设计良好的代码才好写单元测试。
  • 单元测试本质上也是一种文档,它描述了我们写的代码背后的意图。
  • 使得后续重构更安全。(单元测试可以验证重构是否有 bug)
  • 缩短反馈周期、降低缺陷修复成本。(在开发阶段就可以得到反馈,这个时候修复成本是最低的)
  • 保证质量的前提下提升软件交付的速度。(更少的 bug,更快的迭代速度)

单元测试的特征

  • 快速:应该花非常少的时间来运行单元测试。

如果我们有 pull 过一些优秀的开源项目,我们可以运行一下里面的单元测试,我们可能会发现在几秒内就完成了全部代码的单元测试。

  • 独立:单元测试是独立的,可以单独运行。不依赖于其他测试。

独立的一个好处就是可以单独验证某一个逻辑是否正确,如果需要依赖其他测试的话,说明我们的代码还是存在一些设计上的缺陷。 因为这在某种程度上表面,我们的不同逻辑之间存在着强依赖。

  • 可重复:运行单元测试的结果应该保持一致(幂等)

如果我们每次运行的结果都不一样,那我们也无法对程序运行结果进行断言,我们就无法判断运行的结果是否正确。

  • 自检查:测试应该能够在没有任何人工交互的情况下,自动检测测试是否通过。

比如我们不能说跑一下测试,然后去看看数据库有没有写入成功、文件有没有写入成功。 因为这种东西没什么好测的,数据库只要能正确运行那肯定是可以写入的,如果某些异常情况下数据库写入不了,那也不是我们的代码的 bug, 因此我们会 mock 掉数据库访问。而文件读写、RPC 调用之类也是同样的道理。

单元测试测什么

我们上面说了,单元测试是对一个功能块(Function)或者方法(Method)的测试。但不是所有的 “单元” 都需要单元测试的。 既然要做单元测试,我们就要知道要测什么内容。比如下面的代码,需要测试吗?

1
2
3
4
5
6
7
public static Response get(String url) throws IOException {
okhttp3.Request request = new okhttp3.Request.Builder()
.url(url)
.build();

return client.newCall(request).execute();
}

这是一段很常见的 http 调用代码,里面只是根据传递的 url 调用 okhttp 库发起了一个 GET 请求。 假如要单元测试的话,究竟是测试什么?测试那个 url 背后的服务器是否正常运行?测试我本地的网络是否正常?

实际上,这类对外部系统的依赖是不需要测试的,只要能够编译通过,操作系统会保证它的正常执行。 如果它不能正常执行,那也不是我们代码的问题,可能是 url 所在服务器宕机了,或者本地的网络异常了。 但是这跟我们的代码能否正确处理逻辑一点关系都没有。

我们写的业务逻辑不可能说在外部服务器宕机的时候就处理错误了,比如我们的代码里面计算了 1+1,我们断言它等于 2, 然后我们发起了一个 HTTP 请求,但是这个 HTTP 请求异常了,这个时候我也不能说 1+1 不等于 2。 因为我们的这个 1+1=2 的逻辑跟外部的系统没关系。

单元测试测的是我们写的业务逻辑代码。所有跟外部系统的交互都是不需要进行测试的。

如何写单元测试

明确了我们要测试的内容,接下来就得学习一下如何写单元测试了:通过提供预期的输入和预期的结果,与单元的实际运行结果进行比对, 就可以知道单元的工作是否和预期的一致。

所以,写单元测试有三个步骤:

  • 构建输入参数,并预测该输入所产生的输出。
  • 调用要测试的目标方法,获取输出。
  • 检测目标方法的输出是否和预期的输出一致(Assert 断言)。

对同一个目标方法,通过构建各种不同的输入,重复上述步骤,检测各种正常与边界状况和预期是否相符,确保把目标方法的各种可能性都覆盖。

下面是一个简单的例子(PHP):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 单元测试的目标方法
function add(int $a, int $b): int
{
return $a + $b;
}

// 单元测试
// 测试 add 方法
public function testAdd()
{
// 构建输入
$a = 1;
$b = 1;

// 调用目标方法
$sum = $this->add($a, $b);

// 比对输出与期望的值是否一致。
// 如果不一致的话,单元测试不通过,说明我们的目标方法有错误或者我们的期望值有错误。
$this->assertEquals(2, $sum);
}

我们发现,单元测试写起来好像也没那么难是吧。 当然,在实际工作中的需求大多比这个复杂多了,但是单元测试的步骤其实就上面提到的三个:构建输入、调用被测方法、验证输出。

使用 Mock

单元测试其实并不复杂,复杂的其实是我们的代码。 如果想更好地写好单元测试,我们还必须得了解一下单元测试中的 mock。

mock 是单元测试中帮助我们模拟类方法的一种技术。 我们知道了,单元测试不应该对数据库这些外部组件有依赖,那我们该如何实现才能让单元测试没有外部依赖呢? 答案就是 mock,当我们的代码需要依赖某一个类的时候,我们可以使用 mock 库来生成一个模拟的对象, 在我们的代码的代码需要调用这个对象的某些方法的时候,实际上并不会产生实际的调用。 这么说有点抽象,下面是一个非常典型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Adder
{
public function add($a, $b)
{
return $a + $b;
}
}

class Calculator
{
private $adder;

/**
* @param Adder $adder 代表一个对外部的依赖
*/
public function __construct(Adder $adder)
{
$this->adder = $adder;
}

public function add($a, $b)
{
// 这里只使用了外部依赖,实际中可能包含非常多的逻辑
return $this->adder->add($a, $b);
}
}

// 单元测试
public function testCalculator()
{
// 创建一个模拟的 Adder 对象
$adder = Mockery::mock(Adder::class)->makePartial();
// shouldReceive 表明这个 mock 对象的 add 方法会被调用
// once 表明这个方法只会被调用一次(没有 once 调用表示可以被调用任意次数)
// with 如果调用 mock 对象的时候传递了 1 和 2 两个参数,就会返回 andReturn 中的参数
$adder->shouldReceive('add')->once()->with(1, 2)->andReturn(3);

$c = new Calculator($adder);
$this->assertEquals(3, $c->add(1, 2));

$adder = Mockery::mock(Adder::class)->makePartial();
// 没有指定 with,传递任意参数都会返回 3
$adder->shouldReceive('add')->andReturn(3);
$c = new Calculator($adder);
$this->assertEquals(3, $c->add(2, 3));
}

在所有常见的编程语言中,都会有一个比较成熟的 mock 库,比如:

  • PHP 中的 Mockery(上面的例子用的就是 Mockery)
  • Java 中的 Mockito
  • go 中的 testify 也提供了 mock 的功能

有了 Mock,我们就可以实现隔离掉外部依赖的这一目标。 不管是 RPC、数据库还是读写文件等操作,我们都可以使用一个模拟的对象来模拟实际的操作。 这意味着,不管外部系统怎么变化,我们的单元测试如果运行通过了,说明我们写的代码逻辑上是没有问题的。这样我们的单元测试才更加健壮。

在单元测试的时候,我们通常会将外部依赖以 mock 的形式注入到我们的代码中。 这一点各种语言实现上会有比较大的差异,有时候还跟使用的框架相关:

  • PHP 的 Laravel 可以通过 mock 一个对象,然后绑定到容器中,然后通过 app() 来使用框架提供的依赖注入功能,又或者自己 mock 之后直接 new 一个实例来进行测试。
  • Java 的 Spring Boot 的依赖注入更加的方面,直接通过在类字段上加上 @Mock/@InjectMocks 注解即可实现注入了。

有很多的 mock ?

看完上面的讲述,我们可能会兴致勃勃地想去写单元测试。 在我们开始写单元测试之后,可能会感到非常沮丧,一杯茶一根烟一个测试写一天,我们会发现怎么要 mock 这么多东西。 这个时候,我们可能会开始思考,这种 mock 的做法到底对不对,为什么写起来这么费劲呢?

出现这种情况,往往反映的是我们代码背后设计上存在的问题,如果一个类需要依赖很多其他东西,说明这个类本身太复杂了。 这个时候怎么办?那当然是能跑就行!代码跟人有一个能跑就行。

对于遗留系统的代码我们可能无能为力,但是对于我们新增的代码,我们依然有机会去改进, 在一边写新代码,一边写单元测试的过程中,我们可以去思考怎样写出来的代码是可以写单元测试的。 我们可以去看看关于软件设计方面的一些东西,比如郑晔的《软件设计之美》,个人感觉是比较接地气的。 持续地去编写单元测试可以促使我们写出可重用、可推广的代码,以及改进我们的软件设计。

(未完不续。)

launchd 是什么?

launchd 是 macOS 下一个服务管理工具,用于启动、停止和管理守护进程、应用程序、进程和脚本。 我们可以将 launchd 看作是 mac 下的 systemd 或者是 supervisor,如果我们想要在 mac 下启动守护进程,用 launchd 就可以了。

守护进程是在后台运行的不需要用户输入的程序。比如我们常用的 MySQL,往往是以守护进程的方式启动的。 需要注意的是:虽然本文多次提到了 守护进程,但是准确来说,launchd 可以启动的不仅仅是守护进程,还可以启动应用程序、进程和脚本。

如何写 launchd 配置文件?

launchd 的配置文件是通过一个 plist 文件来定义的(plistproperty list 的缩写),一个典型的格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Label 可以看作是守护进程的名称,key 是配置的名称,key 的下一行就是它的值,string 标签表示值的类型是字符串 -->
<key>Label</key>
<string>com.example.app</string>
<key>Program</key>
<string>/Users/Me/Scripts/cleanup.sh</string>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>

说明:

  • 配置文件中,除了 dict 里面的那一部分,其他的都是固定的,不需要修改。
  • 三个字段说明:
    • Label:也就是服务的名字,可以随便取,但是不能重复。我们通过 launchctl list 来查看的时候,列出的就是这个名字。上述例子是 com.example.app
    • Program:要启动的程序的路径,需要填写绝对路径。上述例子是 /Users/Me/Scripts/cleanup.sh如果需要加参数的话,需要使用 ProgramArguments 来代替 Program,详情参考下文。
    • RunAtLoad:是否在配置被加载的时候就运行,默认是 false,如果需要在启动的时候就运行,需要设置为 true。上述例子是 true
  • 标签说明:key 就是属性的名称,紧跟着 key 的下一行就是属性的值,属性的值的类型通过其标签反映出来,比如上面的 <string> 表示包裹的是一个字符串类型,而 <true/> 表示是一个布尔类型,而且它的值是 true

mac 中很多配置都是通过 plist 来定义的,

launchd 配置文件放哪里?

macOS 中有两种类型的守护进程,一种是系统级别的(Daemons),一种是用户级别的(Agents),它们的配置文件放的位置是不一样的。 系统级别的守护进程就是不管你用户是谁,都会启动的,而用户级别的守护进程就是只有在对应的用户登录的时候才会启动的(所以会保存在用户主目录下)。

简单来说,就是如果没有用户登录进系统中,那么用户级别的守护进程(Agents)就不会启动。mac 其实跟 linux 一样,都是多用户系统,没有任何用户登录的时候,它依然是在运行的。

下面是 launchd 配置文件的路径:

  • ~/Library/LaunchAgents:用户级别的守护进程配置文件路径。这里保存特定用户的 Agents 配置。(一般情况都是放这里
  • /Library/LaunchAgents:用户级别的守护进程配置文件路径。这里保存所有用户共用的 Agents 配置。
  • /Library/LaunchDaemons:全局的 Daemons 配置。
  • /System/Library/LaunchAgents:所有登录用户共用的 Agents 配置。
  • /System/Library/LaunchDaemons:全局的 Daemons 配置。

可能大家看得有点迷,会有一种想法就是,那我的配置文件应该放哪里?这个问题的答案很简单:如果你的电脑只有你一个人用,那么你就把配置文件放在 ~/Library/LaunchAgents 下面就行了。

如何让 launchd 开机启动我们配置的守护进程?

在我们添加了配置文件之后,还有一件事需要做的就是,修改配置文件的权限,我们可以参考一下上面几个文件夹中文件的权限,然后修改成相同的就可以了。 比如 ~/Library/LaunchAgents 文件夹中的配置文件权限都是 xx:staffxx 是当前登录的用户),那么我们也把我们的配置文件权限修改成 xx:staff 就可以了。

接下来,我们只需要执行 launchctl load ~/Library/LaunchAgents/com.example.app.plist 就可以了。

如何移除守护进程呢?

如果我们想要移除守护进程,只需要执行 launchctl unload ~/Library/LaunchAgents/com.example.app.plist 就可以了。

如果要执行的命令有参数怎么配置?

在很多时候,我们都是需要加上某些参数来启动我们的命令的,要实现这种效果可以使用 ProgramArguments 配置,下面是另外一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>my-privoxy</string>
<key>ProgramArguments</key>
<array>
<string>/Users/ruby/Code/proxy/privoxy</string>
<string>--no-daemon</string>
<string>privoxy.config</string>
</array>
<key>StandardErrorPath</key>
<string>/Users/ruby/Library/Logs/my-privoxy.log</string>
<key>StandardOutPath</key>
<string>/Users/ruby/Library/Logs/my-privoxy.log</string>
<key>WorkingDirectory</key>
<string>/Users/ruby/Code/proxy/</string>
</dict>
</plist>

这个例子中,我们添加了更多的元素进去,比如日志、工作目录等。

说明:

  • ProgramArguments 属性的作用是指定要执行的命令以及其参数,它的值是数组类型。
  • StandardErrorPath 配置了错误输出的日志路径。
  • StandardOutPath 配置了标准输出的日志路径。
  • WorkingDirectory 设置了我们程序运行时候所在的工作目录。

launchd 配置项说明

Label - 指定名字

1
2
<key>Label</key>
<string>com.example.app</string>

Program、ProgramArguments - 指定要执行的程序

这两个二选一,如果不需要指定参数,用 Program。如果需要指定参数,那么使用 ProgramArguments

1
2
<key>Program</key>
<string>/path/to/program</string>
1
2
3
4
5
6
7
8
<key>ProgramArguments</key>
<array>
<string>/usr/bin/rsync</string>
<string>--archive</string>
<string>--compress-level=9</string>
<string>/Volumes/Macintosh HD</string>
<string>/Volumes/Backup</string>
</array>

EnvironmentVariables - 环境变量

我们可以为 Program 设置环境变量,比如下面这个例子:

1
2
3
4
5
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/bin:/usr/bin:/usr/local/bin</string>
</dict>

这样我们就可以在程序运行的时候读取到这些环境变量。

StandardInPath、StandardOutPath、StandardErrorPath - 重定向输入输出

1
2
3
4
5
6
<key>StandardInPath</key>
<string>/tmp/test.stdin</string>
<key>StandardOutPath</key>
<string>/tmp/test.stdout</string>
<key>StandardErrorPath</key>
<string>/tmp/test.stderr</string>

具体含义:

  • StandardInPath 标准输入的路径。
  • StandardOutPath 标准输出的路径。
  • StandardErrorPath 标准错误输出的路径。

大多时候我们可能不需要,但是如果我们的服务跑不起来,加上 StandardErrorPathStandardOutPath 我们就可以看到错误信息了。

WorkingDirectory - 指定工作目录

1
2
<key>WorkingDirectory</key>
<string>/tmp</string>

这样我们可以在程序运行的时候,直接使用相对路径。

RunAtLoad、StartInterval、StartCalendarInterval - 指定什么时候启动

这三个属性我们可以在配置中选择一个来配置:

  • RunAtLoad:如果设置为 true,那么 Program 会在系统启动的时候执行。(对于 Daemons 来说就是系统启动的时候启动,对于 Agents 来说就是用户登录的时候启动)
  • StartInterval:指定启动的时间间隔,单位是秒。也就是每隔多少秒执行一次。
  • StartCalendarInterval:指定启动的时间,可以指定每天的某个时间启动。(类似定时任务),可以指定多个时间。
1
2
<key>RunAtLoad</key>
<true/>

上面两行的作用是,在系统启动或者用户登进的时候执行命令。

1
2
<key>StartInterval</key>
<integer>3600</integer>

上面两行的作用是,每隔 3600 秒执行一次。

1
2
3
4
5
6
7
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>0</integer>
</dict>

上面的例子中,我们指定了每天的 3 点执行一次。

还有些不太常用的选项:StartOnMountWatchPathsQueueDirectories,通过这几个配置也可以指定命令执行的时机,本文不做介绍。

KeepAlive - 指定是否保持运行

这个默认其实是 true,我们可以测试一下:kill 掉我们 launchd 启动的进程,我们会发现那个进程马上又会被启动。

这个选项中,我们可以配置一些额外的条件来让 launchd 知道什么时候需要重启进程:

  • SuccessfulExit 如果上一次退出是正常退出,那么就在进程退出的时候启动它。
1
2
3
4
5
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<true/>
</dict>
  • Crashed 如果上一次退出是异常退出,那么就在进程退出的时候启动它。
1
2
3
4
5
<key>KeepAlive</key>
<dict>
<key>Crashed</key>
<true/>
</dict>
  • NetworkState 如果网络连接断开,那么就在网络连接恢复的时候启动它。
1
2
3
4
5
<key>KeepAlive</key>
<dict>
<key>Crashed</key>
<true/>
</dict>

KeepAlive 的其他不常用选项:PathStateOtherJobEnabledAfterInitialDemand,本文不做介绍。

UserName、GroupName - 指定运行的用户和用户组

我们可以指定以什么用户、用户组来运行这个命令:

1
2
3
4
5
6
<key>UserName</key>
<string>nobody</string>
<key>GroupName</key>
<string>nobody</string>
<key>InitGroups</key>
<true/>

RootDirectory - 指定根目录

这允许我们在一个 jail root 中执行我们的命令。

1
2
<key>RootDirectory</key>
<string>/var/jail</string>

AbandonProcessGroup - 进程被终止的时候是否终止其子进程

当我们给 launchd 启动的进程发送 SIGTERM 信号的时候,这个 SIGTERM 信号也会同时被发送给它的子进程。 我们可以将 AbandonProcessGroup 设置为 true 来禁止这种行为:

1
2
<key>AbandonProcessGroup</key>
<true/>

ExitTimeOut - 优雅终止

在我们停止 launchd 启动的进程的时候,会先发送一个 SIGTERM 信号,我们的进程可以在接收到这个信号后做一些清理操作。 直到 ExitTimeOut 秒后,如果进程还没退出,那么就会发送一个 SIGKILL 信号来强行终止进程的运行:

1
2
<key>ExitTimeOut</key>
<integer>30</integer>

ThrottleInterval - 命令调用的时间间隔

可与 KeepAlive 配合使用,在进程异常退出之后,间隔 ThrottleInterval 秒后再尝试启动。

launchd 常用操作

launchctl list - 列出所有 launchd 管理的服务

1
2
➜ launchctl list | grep my-ss-local
89201 0 my-ss-local

输出的第一列是进程 id,如果是 0 说明没有在运行状态。第二列的 0 表示的是进程上一次的退出状态码,0 一般表示成功。第三列表示的是我们在 plist 配置文件中配置的 Label 的值。

加载一个 plist(服务/job)

我们可以通过下面的命令加载一个 plist

1
launchctl load ~/Library/LaunchAgents/com.example.app.plist

移除一个 plist(服务/job)

我们可以通过下面的命令来移除 launchd 配置:

1
launchctl unload ~/Library/LaunchAgents/com.example.app.plist

启动一个 job

下面的 com.example.app 是我们在 plist 中配置的 Label 的值:

1
launchctl start com.example.app

停止一个 job

下面的 com.example.app 是我们在 plist 中配置的 Label 的值:

1
launchctl stop com.example.app

一些实例

下面是个人使用中的一些配置文件,供大家参考。下面的例子涵盖了常用的一些配置,我们复制改改就可以用了。

frpc

文件 ~/Library/LaunchAgents/frp.plist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>frpc</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/frpc</string>
<string>-c</string>
<string>/etc/frpc.ini</string>
</array>
</dict>
</plist>

这个配置文件的作用是,以守护进程的方式来启动 /usr/local/bin/frpc -c /etc/frpc.ini 这个命令。启用方式为:

1
launchctl load ~/Library/LaunchAgents/frp.plist

我们可以通过 ps 来查看进程是否成功启动。

ss-local

文件 ~/Library/LaunchAgents/ss-local.plist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>my-ss-local</string>
<key>ProgramArguments</key>
<array>
<string>/Users/ruby/Code/proxy/ss-local</string>
<string>-c</string>
<string>ss-local-config.json</string>
</array>
<key>StandardErrorPath</key>
<string>/Users/ruby/Library/Logs/my-ss-local.log</string>
<key>StandardOutPath</key>
<string>/Users/ruby/Library/Logs/my-ss-local.log</string>
<key>WorkingDirectory</key>
<string>/Users/ruby/Code/proxy/</string>
</dict>
</plist>

这个配置文件的作用是,以守护进程的方式来启动 /Users/ruby/Code/proxy/ss-local -c ss-local-config.json 这个命令, 并且将标准错误输出和标准输出都重定向到 /Users/ruby/Library/Logs/my-ss-local.log 文件中。 ss-local 进程的工作目录是 /Users/ruby/Code/proxy/

启用方式为:

1
launchctl load ~/Library/LaunchAgents/ss-local.plist

homebrew 的 service - MySQL

文件 ~/Library/LaunchAgents/homebrew.mxcl.mysql@5.7.plist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>homebrew.mxcl.mysql@5.7</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/opt/mysql@5.7/bin/mysqld_safe</string>
<string>--datadir=/usr/local/var/mysql</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>WorkingDirectory</key>
<string>/usr/local/var/mysql</string>
</dict>
</plist>

我们在使用 mac 的时候最常用的 homebrew 也是通过 launchd 来管理服务的。我们可以通过 brew services list 来查看当前启动的服务。 brew 管理的服务的配置文件也会被放在 ~/Library/LaunchAgents 目录中。

上面的配置文件中,做了以下配置:

  • KeepAlive - 设置为 true,那么当进程退出的时候,launchd 会自动重启这个进程。
  • Label - 这个配置文件的唯一标识。我们除了通过 brew services list 查看到 MySQL 服务,还可以通过 launchctl list | grep mysql 来看到这个进程的状态。
  • ProgramArguments - 指定要执行的命令。这里的完整命令是 /usr/local/opt/mysql@5.7/bin/mysqld_safe --datadir=/usr/local/var/mysql
  • RunAtLoad - 设置为 true,那么当用户进入系统的时候,会自动启动这个进程。
  • WorkingDirectory - 指定 MySQL 进程的工作目录。

总结

  • mac 下可以使用 launchd 来配置一些守护进程、定时任务等。类似的工具有 systemdsupervisor 等,但是 launchd 是 mac 自带的,不用安装其他依赖就能使用。
  • launchd 配置文件类型为 plist,也就是 property list,用户级别的配置文件一般存放在 ~/Library/LaunchAgents 目录中。
  • launchd 配置的几个关键属性:
    • Label - 用来标识这个 job 的唯一标识符。
    • Program - 指定要执行的命令。(如果有参数,我们需要使用 ProgramArguments 来代替 Program
    • StandardErrorPathStandardOutPath - 指定标准错误输出和标准输出的路径。
    • WorkingDirectory - 指定命令执行的工作目录。
  • launchd 可以通过 launchctl 命令来管理:
    • launchctl list - 列出所有 launchd 管理的服务。
    • launchctl load - 加载一个 plist
    • launchctl unload - 移除一个 plist
    • launchctl start - 启动一个 job
    • launchctl stop - 停止一个 job

launchd 的使用还是挺简单的,命令也就那么几个,所以如果想在你的 mac 中启动一些守护进程的话,可以尝试一下。