Skip to content

Latest commit

 

History

History
718 lines (527 loc) · 27.9 KB

单元测试 - 工具.md

File metadata and controls

718 lines (527 loc) · 27.9 KB
created_at updated_at slug tags
2022-03-05 05:14:47 -0800
2022-03-05 05:14:47 -0800
java-unit-test-tools
Junit5
MockK
AssertK
单元测试

工欲善其事,必先利其器。写单元测试,需要三类工具

  • 测试平台:JUnit、TestNG
  • Mock框架:Mockito、MockK
  • 断言框架:AssertJ、Assertk

当开发语言为kotlin时,推荐JUnit5 + MockK + Assertk的组合。

JUnit

测试框架承载单元测试运行的环境,基础技术。一般会提到JUnit和TestNG,Spring默认集成了JUnit5,后者由于没有具体使用过,不大好作评论,但是网上文章搜了一圈,相比于JUnit5,TestNG并没有看出什么优势,具有差不多的功能,但是需要写xml。处于好奇看了下两者的发布时间,JUnit5诞生的日期、更新的频繁程度都较高,所以倾向使用JUnit5,至于JUnit4,现在已经过时了。

框架 首版发布日期 首个正式版发布日期 最近一个正式版发布日期 更新频率
JUnit4 2014-7-29 2014-7-29 2021-2-14 一年左右
JUnit5 alpha版2016-2-1 2017-10-03 2021-11-29 一两个月一次
TestNG 2010往前 2010往前 2022-1-3 一年左右

概览

注意区分它有4、5两个版本,后者为最新版,也是极力推广的版本,功能丰富了不少。组成如下

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

JUnit Platform:在JVM中启动测试的基础;同时提供开发测试引擎的API

JUnit Jupiter:是写单元测试的编程模型、扩展模型的结合;提供跑基于juipter测试的测试引擎

JUnit Vintage:提供在当前平台跑JUnit3和JUnit4的测试引擎

JUnit5最低支持JDK8

有关JUnit的全部功能,参考官方手册,建议从头到尾看一遍,了解一下有哪些型号的锤子。

基本概念

掌握三个基本概念

  • 测试类

    如下三种类可以被称作静态类,并且它们必须至少包含一个测试方法

    • 顶级类
    • 静态内部类
    • 被@Nested注解的非静态内部类
  • 测试方法

    被@Test,@RepeatedTest, @ParameterizedTest, @TestFactory, or @TestTemplate注解的实例方法

  • 生命周期方法

    被@BeforeAll,@AfterAll, @BeforeEach, or @AfterEach注解的方法

注意事项

  • 除@TestFactory注解的方法外,测试方法不能有返回值
  • 测试类、测试方法不必是public的,但也不能是private的
  • 对于Java来说,JUnit建议省略掉类和方法的public关键字

注解和功能解释

JUnit有21个以上的注解,我们挑选其中最常用的看

注解 说明
@Test 标记一个测试方法
@ParameterizedTest 参数化测试,与数据源注解协同使用
@RepeatedTest 重复执行的方法,即一个方法被执行多次
@DisplayName 显示在测试报告中的名称
注:部分注解有name属性,也可以指定
@Nested 表明被注解的类是非静态的嵌套类;主要用来分组
@Tag 标签,用于过滤
和TestNG中的组概念类似
和JUnit4中的分类概念类似
@BeforeAll
@AfterAll
@BeforeEach
@AfterEach
生命周期方法
@Timeout 方法执行的超时时间,超时则报错
@ExtendWith 声明式扩展
@RegisterExtension 编程式扩展

一个例子

使用Kotlin编写的两个工具测试方法

/**
 * 将类S的同名属性填充到D中
 * 要求源对象的属性必须有getter方法,且,目标对象的属性必须有setter方法,否则复制不会成功
 */
fun <S, D> D.fillWith(source: S): D = apply {
    BeanUtils.copyProperties(source, this)
}
/**
 * 从一个链接中解析出oss的object key,如果它不是oss的链接,解析结果为null
 */
fun String.parseOssObjectKey(): String? {
    if (this.startsWith("http", true)) {
        return URL(this).path.trim('/')
    }
    return null
}

对应的单元测试如下

@DisplayName("全局扩展方法测试")
class ExtensionMethodTest {

    companion object {

        @BeforeAll
        @JvmStatic
        fun beforeAll() {
            println("全局开始")
        }

        @AfterAll
        @JvmStatic
        fun afterAll() {
            println("全局结束")
        }
    }

    @BeforeEach
    fun beforeEach(info: TestInfo) {
        println("${info.displayName}开始")
    }

    @AfterEach
    fun afterEach(info: TestInfo) {
        println("${info.displayName}结束")
    }

    @Nested
    @DisplayName("D.fillWith(S)")
    inner class FillWith {

        @Test
        fun `fail case`() {
            val source = NonInheritanceValClass(
                field1 = "1",
                field2 = 1,
                field3 = false,
                fieldA = listOf<String>()
            )
            val destination = NonInheritanceValClass()

            destination.fillWith(source)

            assertAll {
                assertThat(destination.field1).isNull()
                assertThat(destination.field2).isNull()
                assertThat(destination.field3).isNull()
                assertThat(destination.fieldA).isNull()
            }
        }

    }

    @Nested
    @DisplayName("String.parseOssObjectKey()")
    inner class ParseOssObjectKey {

        @ParameterizedTest(name = ParameterizedTest.ARGUMENTS_WITH_NAMES_PLACEHOLDER)
        @CsvSource(
            value = [
                "https://mylogs-oss.wemore.com/image/hello.jpg, image/hello.jpg",
                "https://mylogs-oss.moumoux.com/image/hello.jpg, image/hello.jpg",
                "http://mylogs-oss.moumoux.com/image/hello.jpg, image/hello.jpg"
            ], nullValues = ["null"]
        )
        fun `just test`(input: String, expectedOutput: String?) {
            val result = input.parseOssObjectKey()
            assertThat(result).isEqualTo(expectedOutput)
        }
    }

}

运行整个测试类,能够得到如下输出

全局开始
fail case()开始
fail case()结束
input=https://mylogs-oss.wemore.com/image/hello.jpg, expectedOutput=image/hello.jpg开始
input=https://mylogs-oss.wemore.com/image/hello.jpg, expectedOutput=image/hello.jpg结束
input=https://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg开始
input=https://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg结束
input=http://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg开始
input=http://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg结束
全局结束
全局扩展方法测试 > D.fillWith(S) > fail case() PASSED
全局扩展方法测试 > String.parseOssObjectKey() > input=https://mylogs-oss.wemore.com/image/hello.jpg, expectedOutput=image/hello.jpg PASSED
全局扩展方法测试 > String.parseOssObjectKey() > input=https://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg PASSED
全局扩展方法测试 > String.parseOssObjectKey() > input=http://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg PASSED

上面的例子可以总结出几个问题,我们一个一个看

  1. 如何启动那个测试类?可能在IEAD中有启动按钮,但是如果只是给了一个类文件,我们要如何启动呢?在CI构建时要如何启动呢?
  2. 为什么要用嵌套类?
  3. @BeforeAll的使用看起来很不方便?
  4. 测试case的生命周期是怎样的?
  5. 参数化测试,还支持别的设置参数的方式吗?

启动

三种启动方式

  • Console Launcher

    提供一个可执行文件 junit-platform-console-standalone-1.8.2.jar ,用如下命令执行测试

    java -jar junit-platform-console-standalone-1.8.2.jar <额外选项>

    这种场景还没用过

  • IDEA插件 —— 开发最常用

    到IDEA的插件市场搜索junit,安装插件就能直接测试(可以看到,junit插件是软件绑定的,默认已经安装了,甚至没有卸载选项)

    image-20220226175402518

    此时写的测试类和方法上就会有运行按钮

    image-20220226175613488
  • gradle插件 —— CI最常用

    使用gradle构建时需要添加junit插件。同样,gradle已经将JUnit添加到test任务的默认支持工具中,同样支持的还有JUnit4、TestNG。详情参考gradle的测试文档,可配置的参数比较多,一个较为简单的配置如下

    test {
        // 使用JUnit5进行测试
        useJUnitPlatform()
        // 测试线程数:2
        maxParallelForks(2)
        // 日志配置
        testLogging {
            // level=LIFECYCLE的配置项
            events "passed", "skipped", "failed"
            exceptionFormat "full"
        }
    }

测试生命周期

一个测试类启动后,生命周期从上到下为(以上面的例子为例)

  • 测试类ExtensionMethodTest加载,执行被@BeforeAll注解的静态方法
  • 测试类创建:ExtensionMethodTest、嵌套类FillWith
  • 执行@BeforeEach注解的方法
  • 执行@Test或@ParameterizedTest注解的方法
  • 执行@AfterEach注解的方法
  • 测试类被丢弃
  • 执行另一个测试方法时,从第二步开始再执行
  • 执行被@AfterAll注解的静态方法

所以重点是

  • 执行每个测试方法,都会重新创建测试类实例。而不是多个测试方法共用一个实例
  • 基于上面的原因,@BeforeAll、@AfterAll注解的方法只能在类加载期间执行,即只能注解到静态方法上。这也解释了为什么非静态嵌套类中无法使用@BeforeAll,因为它无法定义静态方法呀

理解单元测试:单元测试的基本要求之一是测试case之间相互不影响,测试方法可能使用了测试类中定义的变量,为了保证不受其它使用该变量的测试方法的影响,最好的方式就是为该方法专门创建一个测试类实例。这就是JUnit的默认行为,一定要理解。

也十分推荐这么做,这才是真的单元测试。

对于那些多个测试方法确实需要共享一个测试类实例的情况,JUnit也提供支持。使用时要慎重,此时@BeforeAll的行为也会改变。

@TestInstance(Lifecycle.PER_CLASS)
class ExtensionMethodTest {

嵌套类

JUnit提供嵌套类的支持,是为了提供给用户更好的测试之间组关系的支持。

上例中,对每个待测方法,都有多个case需要执行,为了将二者分为两组,我为每个待测方法建立了一个嵌套对象,测试case作为嵌套对象的方法,这样在嵌套类上添加公共的说明,得到的测试报告也更加层次化。

image-20220226182218073

可以想见,如果没有这样的支持,我需要在每个方法的显示名中加上说明,多么麻烦。

参数化测试

利器之二,最典型的应用场景就是针对各种不同输入的测试,参考上例ExtensionMethodTest.ParseOssObjectKey.just test(),特点

  • 使用注解@ParameterizedTest,可以通过name指定测试名称。name指定的字符串中有几个可以使用的占位符

    Placeholder Description
    {displayName} 方法的名称
    {index} 当前调用的参数在参数列表中的索引
    {arguments} 完整的测试方法参数值,逗号分隔
    {argumentsWithNames} 完整的测试方法的参数名和参数值,格式 key1=value1,key2=value2...
    {0}, {1}, … 具体的参数
  • 有定义的现成名称可以使用,如:ParameterizedTest#DISPLAY_NAME_PLACEHOLDER

  • 必须和数据源注解一起使用,支持的注解源(可以叠加使用)

    • @ValueSource:最常用,单个值的列表

    • @NullAndEmptySource、@NullSource。。。:最常用,null或空串

    • @EnumSource

    • @MethodSource:数据来源于一个方法

      一个例子如下,重点是方法要是静态的(除非LifeCycle.PER_CLASS);返回Arguments的集合类型

      		// 定义
          @JvmStatic
          fun provideLegalResource(): List<Arguments> {
              return PathMatchingResourcePatternResolver().getResources(LEGAL_RESOURCE_PATTERN).map { resource ->
                  resource.file.name to publicObjectMapper.readTree(resource.file)
              }.map { (fileName, mockResources) ->
                  mockResources.map { mockResource ->
                      val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX)
                      val comment = (mockResource as ObjectNode).remove(COMMENT_KEY).asText()
                      val content = """{"resources": [${mockResource.toJsonString()}]}"""
                      // 上面都忽略,这才是关键
                      Arguments.of(resourceType, comment, content)
                  }
              }.flatten()
          }
      
      		// 使用
          @ParameterizedTest(name = "{displayName} {argumentsWithNames}")
          @MethodSource("xxx.ResourceProvider#provideLegalResource")
          fun `200 when legal resource`(type: String, comment: String, content: String) {
    • @CsvSource:以CSV的方式提供多个值

    • @CsvFileSource

    • @ArgumentsSource:数据源于一个ArgumentsProvider,这个其实是最通用的方法,上面的所有注解都是用ArgumentsProvider实现的,比如下面这个

      @ArgumentsSource(NullArgumentsProvider.class)
      public @interface NullSource {
      }

构造器和方法的注入

注意上面的beforeEach()方法的参数TestInfo,之所以方法执行时能够获得这个参数,是因为JUnit执行了注入操作。

JUnit支持向测试类的构造器和所有成员方法执行注入操作,对应的API是ParameterResolver。默认实现有三个,有需要可以自己增加

  • TestInfoParameterResolver
  • RepetitionInfoParameterResolver
  • TestReporterParameterResolver

测试的继承

如果有多个类都有相同的测试前置操作、测试case,可以将这些内容抽取成为一个接口。JUnit的注解效果是可以继承的,这是个利器。合理使用。

举例:controller层的单元测试中,接口鉴权是公共的case,就很适合抽出来,每个接口都实现它。

更多内容请参考JUnit用户手册,值得探究的内容

  • 测试顺序

    测试方法之间默认没有顺序,但可通过添加@Order的方式声明顺序

  • 并行执行

    JUnit默认使用一个线程执行所有测试,我们可以指定多个以提升测试执行速度,不过要注意并发问题

  • 条件测试

    测试case在条件满足的情况下才执行

  • 测试模板和动态测试

    当需要动态生成测试方法时,不妨考虑考虑它们

Mockk

单元测试不像集成测试,”单元“二字是关键,一次只测试单个逻辑。其它级联的方法调用,都可以通过mock解决。与之对应的两个概念

  • mock:完全伪造目标,目标可以是对象、静态方法、kotlin的object等
  • spy:在现有的目标上进行mock

如果用Kotlin,mockk一定是个很好的尝试。相对于Mockito,MockK功能全面,支持DSL,书写流畅简单。下面通过一些场景介绍。

基本使用方法

MockK的完整使用方法包括

  • 声明mock对象
  • mock对象的行为
  • 执行被测方法
  • 验证结果、行为、过程参数

如果使用JUnit5,MockK提供MockkExtension,同MockitoExtension,Mockito中对应的@Mock、@Spy、@InjectMocks分别变成@MockK、@SpyK、@InjectMockKs。一个典型的例子

@ExtendWith(MockKExtension::class)
class MediaServiceTest {

  // 声明mock对象
  @MockK
  private lateinit var miscService: MiscService

  // 声明spy对象,同时声明spy的具体对象:mockMediaProperties
  @SpyK
  private var mediaProperties: MediaProperties = mockMediaProperties

  // 声明被测对象,mediaService会被创建,并使用miscService和mediaProperties注入其构造方法
  @InjectMockKs
  private lateinit var mediaService: MediaService

  @Test
  fun `test`() {
    val mockUser = MOCK_USER_ID
    val mockDir = "mockDir"
    // 临时声明的mock对象,GetOssDirMetaResp类的对象
    val mockOssDirMetaResp = mockk<GetOssDirMetaResp>()
    val mockDirMeta = mockk<GetOssDirMetaResp.DirMeta>()
    // 临时创建spy对象,基于被测对象mediaService创建,目标是mock被测对象的行为
    val spyMediaService = spyk(mediaService)
    // mock行为
    every { spyMediaService.parseDir(any()) } returns mockDir
    every { spyMediaService.mediaStub.getOssDirMeta(any()) } returns mockOssDirMetaResp
    every { mockOssDirMetaResp.dirMetaList } returns listOf(mockDirMeta)
    every { mockDirMeta.totalSize } returns 0
		// 调用被测方法
    spyMediaService.getOccupiedSpaceOfUser(mockUser)

    val slot = slot<GetOssDirMetaReq>()
    // 捕获spyMediaService.mediaStub.getOssDirMeta()的参数
    verify { spyMediaService.mediaStub.getOssDirMeta(capture(slot)) }
    // 验证捕获到的参数
    assertThat(slot.captured.app).isEqualTo(mediaProperties.appId)
    assertThat(slot.captured.getDir(0)).isEqualTo(mockDir)
  }
}

解释

  • miscService需要凭空mock,mediaProperties需要在提供的对象基础上mock

  • mediaService的创建依赖于miscService和mediaProperties

  • 除了MockKExtension,也可以直接使用MockKAnnotations.init(this::class)来使其生效

  • 本例测试的是某个参数,使用slot()方法抓取该参数

默认行为

Mockito中,如果不对mock对象的行为做预设,行为默认返回对应返回类型的空值。MockK略有不同,不提供默认行为的mock,但可手动选择开启。

  • @RelaxedMockK : 备注接的对象带有默认值
  • @MockK(relaxed = true):同上
  • mockk<MyObject>(relaxed = true):手动mock出来的MyObject对象行为带有默认值

mock单例对象

object RequestContext {
	val currentUser: Int = 12
}

@Test
fun test() {
  mockkObject(RequestContext)
  every { RequestContext.currentUser } returns 1
  println(RequestContext.currentUser) // 输出1
}

值得一提的是,mock枚举对象也是通过mockkObject完成,毕竟,每个枚举项就是一个单例。

mock静态方法

object RequestContext {
  @JvmStatic
  fun getUserId() = userIdTL.get()
}

@Test
fun test() {
  mockkStatic(RequestContext::getUserId)
  every { RequestContext.getUserId() } returns 1
  println(RequestContext.getUserId()) // 输出1
}

mock构造方法

比如下面这个,MembershipLevelStrategySelector创建,然后调用select()方法,我想要mock select()方法的行为,就要mockMembershipLevelStrategySelector的构造方法

fun doRedeem(currentUser: Int, currentAward: InvitationAwardModel) {
  ... ...
  MembershipLevelStrategySelector(existSubscription, addPrivilegedBo).select().handle()
  ... ...
}

于是mock这样写

mockkConstructor(MembershipLevelStrategySelector::class)
every { anyConstructed<MembershipLevelStrategySelector>().select() } returns mockk<AddPrivilegedStrategy>(relaxed = true)

与SpringBoot集成

springmock项目为MockK集成到SpringBoot中提供支持,主要是支持@MockKBean注解啦。

其它

还有功能如下,不再详述,详情参考官方手册

  • mock链式调用,即一次性mocka.method1().method2(),而不需要单独mock
  • 带有层次结构的mock
  • 带有顺序的verify
  • verify某个方法未被调用(很有用)
  • 自定义answer等

AssertK

与AssertJ对应的,是AssertK。下面展示简单用法,具体参考官方手册

常规

assertThat(person.name).isEqualTo("Hello")
assertThat(person.age, "age").isGreaterThan(20) // age是报错时的显示名
assertThat(nullString).isNotNull().hasLength(4) // 可空判断和字符串判断
assertThat(string).all {
    startsWith("L")
    hasLength(3)
} // 多个同时判断
assertAll {
    assertThat(false).isTrue()
    assertThat(true).isFalse()
} // 更通用的多个同时判断

错误判断

assertThat { throw Exception("error") }.isFailure().hasMessage("wrong")
assertThat { throw Exception("error") }.isFailure().matchPreticate {
  // 自己的判断
}

个人认为断言库的两个关键点是使用的简单性和报错信息的展示,AssertK做的都还不错。

如果觉得不够用,也有自定义断言可选。

Spring Boot Test

SpringBoot的test模块,默认包含的测试工具JUnit5、Mockito、AssertJ。相对于此,额外的功能是提供Spring上下文。

@SpringBootTest

在测试类上添加此注解是最为简单的方式。它做了如下几件事

  • 通过SpringApplication创建ApplicationContext

  • 默认情况下它不会启动server。但是可以通过webEnvironment指定环境,默认为MOCK

    • MOCK:创建一个web类型的ApplicationContext、一个web类型的Environment。SpringBoot内嵌的Server不会启动。如果类路径中没有web相关的类可提供。则回滚创建一个非web的ApplicationContext。

      可以结合@AutoConfigureMockMvc和@AutoConfigWebTestClient使用。前者提供一个服务mock,后者提供一个客户端mock终端

    • RANDOM_PORT:创建WebApplicationContext,创建真实的Server,内嵌Server被启动,端口随机

    • DEFINED_PORT:同上,只不过端口跟随配置文件

    • NONE:创建普通的ApplicationContext

  • 当Spring MVC、Spring Webflux任何一个被检测到,就创建对应的web环境。如果都存在,则创建MVC环境。此时想要使用webflux,只能@SpringBootTest(properties = "spring.main.web-application-type=reactive")

可见,@SpringBootTest非常重,会扫描并加载所有Bean。但实际我们往往只会测试一部分内容,对此Spring提供了部分装载的功能,相当于@SpringBootTest的子集。它基于Spring的自动配置机制,现有支持可在这里查看

依据实际使用的经验,Spring提供的测试机制并不友好,往往过于复杂,所以能不用就不用。相反,它更适合集成测试时使用,而不是单元测试。

@MockBean和@SpyBean

被此二者注解的属性,会使用Mockito生成一个mock对象,然后注入到容器中,其他地方可以自由注入。以@MockBean为例,它有如下特性

  • 使用@SpringBootTest时,该功能默认开启。其它情况使用时,需要手动开启。添加两个监听器

    @ContextConfiguration(classes = MyConfig.class)
    @TestExecutionListeners({ MockitoTestExecutionListener.class, ResetMocksTestExecutionListener.class })
    class MyTests 
  • 它定义的是Mockito的Mock

  • 每个test方法结束后,被Mock的Bean会被reset

  • 例子如下:Reverser使用真实对象;RemoteService使用Mock出来的Bean(覆盖原对象中的Bean)

    @SpringBootTest
    class MyTests {
    
      @Autowired
      private Reverser reverser;
    
      @MockBean
      private RemoteService remoteService;
    
      @Test
      void exampleTest() {
        given(this.remoteService.getValue()).willReturn("spring");
        String reverse = this.reverser.getReverseValue(); // Calls injected RemoteService
        assertThat(reverse).isEqualTo("gnirps");
      }
    
    }

前文说过,使用MockK后,换成@MockKBean,由springmock库提供支持

MockMvc

MockMvc是个好东西,与之相对的,是端到端测试(即服务启动,然后使用客户端手动访问)。使用它能够很好滴对Spring MVC构建的端点进行测试,同时支持Kotlin DSL,一个简单的例子

@ControllerTest(SyncController::class)
class SyncControllerTest {
  @Autowired
  private lateinit var mockMvc: MockMvc

  @MockkBean
  private lateinit var resourceService: ResourceService

  @Test
  fun `test`() {
    mockMvc.post("/v2/resources/push") {
      contentType = MediaType.APPLICATION_JSON
      content = PushReqV2Dto().apply { resources = null }.toJsonString()
    }.andExpect {
      status {
        isBadRequest()
      }
      header {
        exists("TOKEN")
      }
      content {
        contentType(MediaType.APPLICATION_JSON)
      }
    }
  }
}

DSL的使用也相当简单,整体而言包括两个

  • 填充请求参数
    • 请求方式:get、post,或者中性的perform
    • 请求路径
    • 请求参数:头部、参数、body
  • 填充预期内容
    • 状态码
    • 响应结果:头部、body等

通过代码提示,也能知道,有三种类型的API调用

  • andReturn():返回执行结果,以MvcResult封装
  • andExpect{}:DSL,填充一些断言
  • andDo{}:DSL,这里可以做一些中性的实行,比如打印出响应结果。

image-20220305125110742

这是Spring构建的Web应用的测试利器

但正如Spring手册所分类的那样,其实这玩意儿被分类在集成测试的

image-20220305125736965

一点想法

想法1:眼高手低。收集资料时想着有好多东西可以写;写的时候又发现如果要把那些都写完,衍生出来的知识太多了,根本无暇顾及。于是拖延,但一想不行,要继续写呀,于是一减再减,成了现在看到的这个样子,不甚满意。但总好过没有。

想法2:写这类学习的文章,还是要趁新鲜。一些内容,刚看到时会有惊艳的感觉,觉得值得一写。习惯之后又觉得理所当然,有什么好写的。殊不知,数月后,对那些认为理所当然的内容,我又恢复成萌新的状态。又要从头开始学习,到时连个回顾的纲要都没有。或许从另一面证明了:写这玩意儿,叫做沉淀🤔

参考文档