0%

什么是动态代理?

在 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 中启动一些守护进程的话,可以尝试一下。

gitlab 可能大家很常用,CI、CD 也应该早有耳闻,但是可能还没有去真正地了解过,这篇文章就是我对 gitlab CI、CD 的一些理解,以及踩过的一些坑,希望能帮助到大家。

什么是 CI、CD

CI(Continuous Integration)持续集成,CD(Continuous Deployment)持续部署(也包含了持续交付的意思)。

CI 指的是一种开发过程的的自动化流程,在我们提交代码的时候,一般会做以下操作:

  • lint 检查,检查代码是否符合规范
  • 自动运行测试,检查代码是否能通过测试

这个过程我们可以称之为 CI,也就是持续集成,这个过程是自动化的,也就是说我们不需要手动去执行这些操作,只需要提交代码,这些操作就会自动执行。

CD 指的是在我们 CI 流程通过之后,将代码自动发布到服务器的过程,这个过程也是自动化的。 在有了前面 CI 的一些操作之后,说明我们的代码是可以安全发布到服务器的,所以就可以进行发布的操作。

为什么要使用 CI、CD

实际上,就算没有 CI、CD 的这些花里胡哨的概念,对于一些重复的操作,我们也会尽量想办法会让它们可以自动化实现的,只不过可能效率上没有这么高,但是也是可以的。

CI、CD 相比其他方式的优势在于:

  • 一次配置,多次使用:我们需要做的所有操作都通过配置固定下来了,每次提交代码我们都可以执行相同的操作。
  • 可观测性:我们可以通过 CI、CD 的日志来查看每次操作的执行情况,而且每一次的 CI、CD 执行的日志都会保留下来,这样我们就可以很方便地查看每一次操作的执行情况。
  • 自动化:我们不需要手动去执行 CI、CD 的操作,只需要提交代码,CI、CD 就会自动执行。
  • 少量配置:一般的代码托管平台都会提供 CI、CD 的功能,我们只需要简单的配置一下就可以使用了。同时其实不同平台的 CI、CD 配置也是有很多相似之处的,所以我们只需要学习一种配置方式,就可以在不同平台上使用了。

gitlab CI、CD

在开始之前,我们可以通过下图来了解一下 CI、CD 的整体流程:

gitlab_1
  1. 在开发人员提交代码之后,会触发 gitlab 的 CI 流水线。也就是上图的 CI PIPELINE,也就是中间的部分。
  2. 在 CI 流水线中,我们可以配置多个任务。比如上图的 buildunit testintegration tests 等,也就是构建、单元测试、集成测试等。
  3. 在 CI 流水线都通过之后,会触发 CD 流水线。也就是上图的 CD PIPELINE,也就是右边的部分。
  4. 在 CD 流水线中,我们可以配置多个任务。比如上图的 stagingproduction 等,也就是部署到测试环境、部署到生产环境等。

在 CD 流程结束之后,我们就可以在服务器上看到我们的代码了。

gitlab CI、CD 中的一些基本概念

在开始之前,我们先来了解一下 gitlab CI、CD 中的一些基本概念:

  • pipeline:流水线,也就是 CI、CD 的整个流程,包含了多个 stage,每个 stage 又包含了多个 job
  • stage: 一个阶段,一个阶段中可以包含多个任务(job),这些任务会并行执行,但是下一个 stagejob 只有在上一个 stagejob 执行通过之后才会执行。
  • job:一个任务,这是 CI、CD 中最基本的概念,也是最小的执行单元。一个 stage 中可以包含多个 job,同时这些 job 会并行执行。
  • runner:执行器,也就是执行 job 的机器,runner 跟 gitlab 是分离的,runner 需要我们自己去安装,然后注册到 gitlab 上(不需要跟 gitlab 在同一个服务器上,这样有个好处就是可以很方便实现多个机器来同时处理 gitlab 的 CI、CD 的任务)。
  • tag: runnerjob 都需要指定标签,job 可以指定一个或多个标签(必须指定,否则 job 不会被执行),这样 job 就只会在指定标签的 runner 上执行。
  • cache: 缓存,可以缓存一些文件,这样下次流水线执行的时候就不需要重新下载了,可以提高执行效率。
  • artifacts: 这代表这构建过程中所产生的一些文件,比如打包好的文件,这些文件可以在下一个 stage 中使用,也可以在 pipeline 执行结束之后下载下来。
  • variables:变量,可以在 pipeline 中定义一些变量,这些变量可以在 pipeline 的所有 stagejob 中使用。
  • services:服务,可以在 pipeline 中启动一些服务,比如 mysqlredis 等,这样我们就可以在 pipeline 中使用这些服务了(常常用在测试的时候模拟一个服务)。
  • script: 脚本,可以在 job 中定义一些脚本,这些脚本会在 job 执行的时候执行。

CI、CD 的工作模型

我们以下面的配置为例子,简单说明一下 pipelinestagejob 的工作模型,以及 cacheartifacts 的作用:

ci 配置文件(也就是一个 pipeline 的所有任务):

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 定义一个 pipeline 的所有阶段,一个 pipeline 可以包含多个 stage,每个 stage 又包含多个 job。
# stage 的顺序是按照数组的顺序来执行的,也就是说 stage1 会先执行,然后才会执行 stage2。
stages:
- stage1 # stage 的名称
- stage2

# 定义一个 job,一个 job 就是一个任务,也是最小的执行单元。
job1:
stage: stage1 # 指定这个 job 所属的 stage,这个 job 只会在 stage1 执行。
script: # 指定这个 job 的脚本,这个脚本会在 job 执行的时候执行。
- echo "hello world" > "test.txt"
tags: # 指定这个 job 所属的 runner 的标签,这个 job 只会在标签为 tag1 的 runner 上执行。
- tag1
# cache 可以在当前 pipeline 后续的 job 中使用,也可以在后续的 pipeline 中使用。
cache: # 指定这个 job 的缓存,这个缓存会在 job 执行结束之后保存起来,下次执行的时候会先从缓存中读取,如果没有缓存,就会重新下载。
key: $CI_COMMIT_REF_SLUG # 缓存的 key(也可以是文件名列表,那样对应的)
paths: # 缓存的路径
- node_modules/
artifacts: # 指定这个 job 的构建产物,这个构建产物会在 job 执行结束之后保存起来。可以在下一个 stage 中使用,也可以在 pipeline 执行结束之后下载下来。
paths:
- test.txt

job2:
stage: stage1
script:
- cat test.txt
tags:
- tag1
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
# 指定这个 job 的缓存策略,只会读取缓存,不会写入缓存。默认是既读取又写入,在 job 开始的时候读取,在 job 结束的时候写入。
# 但是实际上,只有在安装依赖的时候是需要写入缓存的,其他 job 都使用 pull 即可。
policy: pull


# job3 和 job4 都属于 stage2,所以 job3 和 job4 会并行执行。
# job3 和 job4 都指定了 tag2 标签,所以 job3 和 job4 只会在标签为 tag2 的 runner 上执行。
# 同时,在 job1 中,我们指定了 test.txt 作为构建产物,所以 job3 和 job4 都可以使用 test.txt 这个文件。
job3:
stage: stage2
script:
- cat test.txt
tags:
- tag1
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
policy: pull

job4:
stage: stage2
script:
- cat test.txt
tags:
- tag1
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
policy: pull

上面的配置文件的 pipeline 执行过程可以用下面的图来表示:

gitlab_2

说明:

  1. 上面的图有两个 pipeline 被执行了,但是 pipeline2 没有全部画出来
  2. 其中,在 pipeline 1 中,stage1 中的 job 会先被执行,然后才会执行 stage2 中的 job
  3. stage1 中的 job1job2 是可以并行执行的,这也就是 stage 的本质上的含义,表示了一个阶段中不同的任务,比如我们做测试的时候,可以同时对不同模块做测试。
  4. job1job2 都指定了 tag1 标签,所以 job1job2 只会在标签为 tag1runner 上执行。
  5. job1 中,我们创建了一个 test.txt 文件,这个文件会作为 stage1 的构建产物,它可以在 stage2 中被使用,也就是 job3job4 都可以读取到这个文件。一种实际的场景是,前端部署的时候,build 之后会生成可以部署的静态文件,这些静态文件就会被保留到部署相关的 stage 中。需要注意的是,artifacts 只会在当前 pipeline 后续的 stage 中共享,不会在 pipeline 之间共享。
  6. 同时,在 job1 中,我们也指定了 cache,这个 cache 会在 job1 执行结束之后保存起来,不同于 artifactscache 是可以在不同的 pipeline 之间共享的。一种很常见的使用场景就是我们代码的依赖,比如 node_modules 文件夹,它可以加快后续 pipeline 的执行流程,因为避免了重复的依赖安装。

需要特别注意的是:cache 是跨流水线共享的,而 artifacts 只会在当前流水线的后续 stage 共享。

gitlab runner 和 executor

gitlab runner 在 CI/CD 中是一个非常重要的东西,因为我们写的 CI/CD 的配置就是在 runner 上运行的,如果我们想要执行 CI/CD 任务,我们必须先安装配置 gitlab-runner

其中 runner 是一台执行 CI/CD 脚本的机器(也就是安装了 gitlab-runner 的机器)。这个机器可以部署在 gitlab 服务器以外的任意一台电脑上,当然也可以跟 gitlab 在同一台服务器。

而每一个 runner 会对应一种特定的 executorexecutor 就是我们执行 CI/CD 里面 script 的环境。比如如果我们指定了 executor 类型为 docker,那么我们 CI/CD 脚本里面的 script 将会在一个独立的 docker 容器中执行。

简单来说,runner 是执行 CI/CD 脚本的机器,这个机器上有不同类型的 executor,一个 executor 代表着一个不同类型的命令行终端,最常见的是 shelldocker,当然也支持 widnows 的 powershell

我们可以通过下图来了解一下 gitlab 是怎么跟 runner 配合的:

gitlab 是通过 tags 来找到运行脚本的 runner 的,如果 jobtagsrunnertags 匹配了,就可以将那个 job 放到 runner 上处理。

gitlab_3

说明:

  • 我们在两台机器上安装了 gitlab-runner,它们的 IP 是 192.168.2.123192.168.2.234
  • test-jobtags 中包含了 api 这个 tag,而 runner1tags 也包含了 api 这个 tag,因此 test-job 会被 runner1 执行。
  • 同理,npm-install 这个 job 会被 runner3 处理。

从上图我们可以看到,其实不同的 runner 是有可能位于不同的机器上的。

其他一些在个人实践中的一些经验

gitlab 的 CI、CD 是一个很庞大的话题,同时很多内容可能比较少用,所以本文只是介绍个人在实践中用到的一些内容,其他的东西如果有需要,可以自行查阅官方文档。

指定特定分支才会执行的 job

这个算是基本操作了,我们可以通过 only 来指定特定分支才会执行的 job,也有其他方法可以实现,比如 rules,具体请参考官方文档。

1
2
3
4
5
deploy-job:
stage: deploy
# 当前的这个 job 只会在 master 分支代码更新的时候会执行
only:
- "master"

不同 job 之间的依赖

这个也是基本操作,我们可以通过 needs 来指定不同 job 之间的依赖关系,比如 job1 依赖 job2,那么 job1 就会在 job2 执行完毕之后才会执行。

1
2
3
4
job1:
stage: deploy
needs:
- job2

指定执行 job 的 runner

我们可以通过 tags 来指定 job 执行的 runner,比如我们可以指定 job 只能在 api 标签的 runner 上执行。

1
2
3
4
build-job:
stage: build
tags:
- api

如果我们没有标签为 apirunner,那么这个 job 就会一直不会被执行,所以需要确保我们配置的 tag 有对应的 runner

指定 job 的 docker image

注意:这个只在我们的 runnerexecutordocker 的时候才会生效。也就是我们的 runner 是一个 docker 容器。

有时候,我们需要执行一些特定命令,但是我们全局的 docker 镜像里面没有,可能只需要一个特定的 docker 镜像,这个时候我们可以通过 image 来指定 jobdocker 镜像。

1
2
3
4
5
6
7
8
9
deploy-job:
stage: deploy
tags:
- api
# 指定 runner 的 docker image
image: eleven26/rsync:1.3.0
script:
# 下面这个命令只在上面指定的 docker 镜像中存在
- rsync . root@example.com:/home/www/foo

为我们的集成测试指定一个 service

在我们的 CI 流程中,可能会有一些集成测试需要使用到一些服务,比如我们的 mysql,这个时候我们可以通过 services 来指定我们需要的服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
test_rabbitmq:
# 这会启动一个 rabbitmq 3.8 的 docker 容器,我们的 job 就可以使用这个容器了。
# 我们的 job 可以连接到一个 rabbitmq 的服务,然后进行测试。
# 需要注意的是,这个容器只会在当前 job 执行的时候存在,执行完毕之后就会被删除。所以产生的数据不会被保留。
services:
- rabbitmq:3.8
stage: test
only:
- master
tags:
- go
script:
# 下面的测试命令会连接到上面启动的 rabbitmq 服务
- "go test -v -cover ./pkg/rabbitmq"

复用 yaml 配置片段

yaml 中,有一种机制可以让我们复用 yaml 配置片段,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 发布代码的 job
.deploy-job: &release-job
tags:
- api
image: eleven26/rsync:1.3.0
script:
- rsync . root@example.com:/home/www/foo

deploy-release:
<<: *release-job
stage: deploy
only:
- "release"

deploy-master:
<<: *release-job
stage: deploy
only:
- "master"

上面的代码中,我们定义了一个 release-job 的配置片段,然后在 deploy-releasedeploy-master 中,我们都引用了这个配置片段,这样我们就可以复用这个配置片段了。 等同于下面的代码:

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
# 发布代码的 job
.deploy-job: &release-job
tags:
- api
image: eleven26/rsync:1.3.0
script:
- rsync . root@example.com:/home/www/foo

deploy-release:
tags:
- api
image: eleven26/rsync:1.3.0
script:
- rsync . root@example.com:/home/www/foo
stage: deploy
only:
- "release"

deploy-master:
tags:
- api
image: eleven26/rsync:1.3.0
script:
- rsync . root@example.com:/home/www/foo
stage: deploy
only:
- "master"

yaml 的术语中,这一种机制叫做 anchor

cache vs artifacts

初次使用的人,可能会对这个东西有点迷惑,因为它们好像都是缓存,但是实际上,它们的用途是不一样的。

  • cache 是用来缓存依赖的,比如 node_modules 文件夹,它可以加快后续 pipeline 的执行流程,因为避免了重复的依赖安装。
  • artifacts 是用来缓存构建产物的,比如 build 之后生成的静态文件,它可以在后续的 stage 中使用。表示的是单个 pipeline 中的不同 stage 之间的共享

指定 artifacts 的过期时间

我们可以通过 expire_in 来指定 artifacts 的过期时间,比如:

1
2
3
4
5
6
7
8
9
10
11
job1:
stage: build
only:
- "release"
image: eleven26/apidoc:1.0.0
tags:
- api
artifacts:
paths:
- public
expire_in: 1 hour

因为我们的 artifacts 有时候只是生成一些需要部署到服务器的东西,然后在下一个 stage 使用,所以是不需要长期保留的。所以我们可以通过 expire_in 来指定一个比较短的 artifacts 的过期时间。

cache 只 pull 不 push

gitlab CI 的 cache 有一个 policy 属性,它的值默认是 pull-push,也就是在 job 开始执行的时候会拉取缓存,在 job 执行结束的时候会将缓存指定文件夹的内容上传到 gitlab 中。

但是在实际使用中,我们其实只需要在安装依赖的时候上传这些缓存,其他时候都只是读取缓存的。所以我们在安装依赖的 job 中使用默认的 policy,而在后续的 job 中,我们可以通过 policy: pull 来指定只拉取缓存,不上传缓存。

1
2
3
4
5
6
7
8
9
10
11
12
job:
tags:
- api
image: eleven26/rsync:1.3.0
cache:
key:
files:
- composer.json
- composer.lock
paths:
- "vendor/"
policy: pull # 只拉取 vendor,在 job 执行完毕之后不上传 vendor

cache 的 key 使用文件

这一个特性是非常有用的,在现代软件工程的实践中,往往通过 *.lock 文件来记录我们使用的额依赖的具体版本,以保证在不同环境中使用的时候保持一致的行为。

所以,相应的,我们的缓存也可以在 *.lock 这类文件发生变化的时候,重新生成缓存。上面的例子就使用了这种机制。

script 中使用多行命令

script 中,我们可以使用多行命令,比如:

1
2
3
4
5
6
7
job:
script:
# 我们可以通过下面这种方式来写多行的 shell 命令,也就是以一个竖线开始,然后换行
- |
if [ "$release_host" != "" ]; then
host=$release_host
fi

CD - 如何同步代码到服务器

如果我们的项目需要部署到服务器上,那么我们还需要做一些额外的操作,比如同步代码到服务器上。 如果我们的 gitlab 是通过容器执行的(也就是说 gitlab 是通过 docker 启动的),或者我们的 runner 的 executor 是 docker,那么有一种比较常见的方法是通过 ssh 私钥来进行部署。

我们可以通过以下流程来实现:

  1. 新建一对 ssh key,比如 id_rsaid_rsa.pub
  2. id_rsa.pub 的内容添加到服务器的 authorized_keys 文件中。
  3. id_rsa 上传到 gitlab 中(在项目的 CI/CD 配置中,配置一个变量,变量名为 PRIVATE_KEY,内容为 id_rsa 的内容,类型为 file)。
  4. 在我们的 ci 配置文件中,添加如下配置即可:
1
2
3
4
5
6
7
8
9
10
11
12
before_script:
- chmod 600 $PRIVATE_KEY

deploy:
stage: deploy
image: eleven26/rsync:1.3.0
script:
# $user 是 ssh 的用户
# $host 是 ssh 的主机
# $port 是 ssh 的端口
# $PRIVATE_KEY 是我们在 gitlab 中配置的私钥
- rsync -az -e "ssh -o StrictHostKeyChecking=no -p $port -i $PRIVATE_KEY" --delete --exclude='.git' . $user@$host:/home/www

这里的 rsync 命令中,我们使用了 -o StrictHostKeyChecking=no 参数,这是为了避免每次都需要手动输入 yes 来确认服务器的指纹。

安全最佳实践:

  • 为每一个 project 配置 ssh key 变量,如果是全局变量的话,其他 project 可以在未授权的情况下,访问到这个私钥,这是非常危险的。
  • 使用单独的仓库来保存 ci 配置文件,防止其他人未经授权就修改 ci 配置文件,这也是非常危险的。

必须严格遵循以上两步,否则会造成严重的安全问题。因为拿到了私钥,就等于拿到了我们的服务器密码。

ERROR: Job failed: exit code xx 解决方案

我们在使用的时候可能会经常遇到这种错误(在 job 执行的输出里面),如果运气好,在输出里面也有一些额外的错误信息, 这种是最好处理的,它已经告诉你错误原因了。还有一种非常坑爹的情况是:job 失败了,只有一个非 0 的退出状态码,但是没有任何的报错信息,这种情况就比较难处理(更加坑爹的是,偶尔出现这种失败)。

job script 的执行流程

如果我们理解了 gitab CI/CD 中 job 的执行原理,那么这个问题其实就很好解决了,jobscript 执行流程如下:

  1. 拿到 script 中第一条命令,然后执行。
  2. 检查上一步的退出状态码,如果状态码为 0,继续执行下一条命令。否则,job 直接失败,然后显示信息 ERROR: Job failed: exit code <xx>,最后的 <xx> 就是上一条命令的非 0 的那个退出状态码。
  3. 按以上两个步骤来一条条执行 script 中的命令。

如果使用的是 bash shell,我们可以通过 echo $? 来获取上一条命令的退出状态码。状态码方面的约定都是:0 表示成功,非 0 表示不成功。

解决方法

知道了 job 的执行原理之后,问题就很好解决了,我们只需要在 job 执行日志中找到最后那一条命令即可:

  1. 先看这个命令是否有执行失败相关的错误输出信息,如果有,那么解决对应错误即可。
  2. 如果这个执行失败的命令,一点输出都没有。那么我们可以深入了解一下这个命令的退出状态码什么时候等于我们 job 的状态码,然后再对症下药。

一个实例

下面是一个 job 日志的最后几行,但是不包含具体的错误信息:

1
2
3
$ if (( $need_restart_queue == 1 )); then ssh $user@$host "supervisorctl restart xx"; fi
Cleaning up project directory and file based variables
ERROR: Job failed: exit code 1, no message

第一行是执行的命令,这个命令中,通过 ssh 执行了一条远程命令,然后退出。第二行是 job 失败后做清理操作输出的日志,最后一行输出 job 失败的错误码。

就是这个错误,困扰了我几天,因为它是偶尔失败的。

在这个例子中,比上面说到的要复杂一点,这里通过了 ssh 来执行远程命令,如果通过 ssh 执行远程命令,那么 ssh 命令的退出状态码就是执行的那个远程命令的退出状态码。 明确了这一点,我们就可以把问题定位在那个远程命令 supervisorctl restart xx 上,也就是说我们的失败是因为这个命令导致的。

后面排查发现,supervisorctl 命令本身就有一定几率失败,针对这种情况,有两种解决方案:

  1. 重试,可以给 job 指定重试次数,可以是 0~2,也就是说 gitlab 的 job 最多可以重试 2 次。
  2. 忽略这个错误,使用其他解决方案。(我们可以在 ssh 命令后面加上 || true 来忽略,加上这个,命令退出状态码一定是 0 了)

我是采取了后面那一种解决方法,因为服务器上还有一个定时任务来检测对应的进程,如果进程不存在,则会使用 supervisorctl start xx 来启动对应的服务。

总结

最后,总结一下本文中一些比较关键的内容:

  • gitlab 中的一些基本概念:
    • pipeline:代表了一次 CI 的执行过程,它包含了多个 stage
    • stage:代表了一组 job 的集合,stage 会按照顺序执行。
    • job:代表了一个具体的任务,比如 buildtestdeploy 等。
  • 一个 stage 中的多个 job 是可以并行执行的。但是下一个 stagejob 必须要等到上一个 stage 的所有 job 都执行完毕之后才会执行。
  • cacheartifacts 的区别:
    • cache 是用来缓存依赖的,比如 node_modules 文件夹,它可以加快后续 pipeline 的执行流程,因为避免了重复的依赖安装。
    • artifacts 是用来缓存构建产物的,比如 build 之后生成的静态文件,它可以在后续的 stage 中使用。表示的是单个 pipeline 中的不同 stage 之间的共享
  • cache 在安装依赖的 job 中才需要使用默认的 policy,也就是 pull-push,在其他不需要安装依赖的 job 中使用 pull 就可以了,不需要上传缓存。
  • cachekey 可以指定多个文件,这样在指定的文件变动的时候,缓存会失效,这往往用在依赖相关的文件中。
  • 可以使用 services 关键字来指定需要启动的服务,比如 mysqlredis 等,在 job 中可以连接到这些 services,从而方便进行测试。
  • 可以使用 yamlanchor 机制来复用一些配置片段,可以少写很多重复的配置。
  • 一个 job 必须运行在某个 runner 上,jobrunner 的关联是通过 tag 来指定的。