2021年年度总结

2021 年即将结束了,那么是时候总结一下今年我做了哪些工作和事情了。

此前 20 年的时候,我还在 xd 继续做着页面特效,后来工作发生了调整,去了 TapTap ,在 TapTap 这边做回了 2B 业务,算是重回了“安逸圈”(毕竟对兼容性的要求没这么高了)

一开始负责的是开发者中心的任务。总体来讲,由于时间的问题,开发者中心项目上其实没有非常好的规划,不过从整个技术架构上,已经开始使用比较先进的理念和架构。

开发者中心主要用的还是 vue2 。当然这是由于开工的时候, vue3 尚未 release 。显然 vue3 在当时那个阶段 是不适合直接使用的。

为此我们又想再使用 vue2 的基础上去使用 vue3 composition 的复用能力,这最好的方案就是直接用 @vue/composition-api,它带来了巨大的提升,事实上我们的许多基础建设都依赖于 composition api 提供的巨大支持。

从实践的结果来看,开发者中心总体上结果很不错,里面许多的基础建设也逐渐的演变为通用的前端基础设施。

后来主要去负责了星云平台,由于星云主要是一个对内的平台,基于此,这里许多相对激进的方案得以施展。

Typescript & vue 以及…组合式API

vue2 类型的巨大痛点

早在开发者彻底重构之前,开发者其实是一个普通的 vue2 项目,那么从我们接手后来看,既然要 重构 了,不如重构的 彻底一点

从技术开发体验来讲,至少从开发者中心早期版本的实现来看,vue2 的体验是堪忧的,但属于 又不是不能用优雅流畅开发 的中间态。

从这个角度看,vue2 属于非常典型的上手容易,不容易写崩,但是很难写好的框架。

不论你用的是 class component 还是 option api,你都 无可避免的 缺失重要的 类型提示语法检测

从我长期用以来的开发经验来看,在日常开发中,有 超过 80% 的 bug 都是由于 语法错误 导致的。

再者开发者中心势必将会是一个多人协作注重复用 的项目,为了保证可以和 vue 结合,那么最好的方案无非是利用 mixin 做一些复用。

或者更高级一点的,利用 vue 的某些 “特性”(比如 observable,watch)在 ”脱离 vue 实体“ 的基础上实现 一定程度上 的解耦。

从这个角度讲,我们期望上有这么几点:

  • 应当使用 typescript
  • 应当尽可能得支持自动类型推导, 自动补全
  • 应当可以组合功能而非继承

那么受困于 ts 对 vue 的 支持能力堪忧(事实上基于模板的 vue 显然要比 tsx 更容易实现解析,但是这块却做的不好),以及 vue 本身对 ts 的兼容不佳(vue2 只是支持了 dts 而非基于 ts 去实现的,而 vue 本身的功能并不是完全 js 化的,所以反倒是使得其 ts 体验反倒是不如 react 的)。

当然了,在 vue 中所有的类型推导都依赖于 this,这带来的现实就是 非常难做到 良好的 类型定义类型推导

在开发者中心 1.1 版本中我们已经受够了一个问题——修改了一个类型,不会提示其他相关对应问题的修改,所以从我的角度来看,非常迫切的需要解决这个问题。

结合 composition 来带的优势

上一项中提到的问题,在 vue3 中有很大一部分得以解决 —— 得益于 composition 的思想。

在基于 composition api 的前提下,我们实现了一套通用的工具包,来提供一些必要的方法封装。

下面举个请求的例子。

在旧有的项目中如果需要做一个请求,那么必然需要这样实现:

export default {
  data () {
    return {
      loading: false,
      data: null
    };
  },
  methods: {
    async send () {
      try {
        this.loading = true;
        if (request.alreadySend) {
          request.cancel();
        }
        // 假装做了一个请求
        const data = request.doGet();
        this.data = data;
        return data
      } catch (e) {
        this.loading = false;
        this.data = null;
        throw e;
      }
    }
  }
}

显然如果要 复用 这个方法呢?当然有办法,可以把它改造成 mixin, 但是变量重命名的问题还是存在。

诚然你可以基于类似于 new Vue({mixins}) 的方法去实现,但是总的来说并 不如直接用 composition api (至少这个 mixin 的写法要做类型提示要困难不少,但并不是不能做)。

那么下面就可以把目光转移到 composition api 上了,他的类型提示和检测要 更好一点,因为我们可以这样写了:

export function useRequest(request) {
  const data = ref(null);
  const loading = ref(false);
  return {
    data,
    loading,
    send () {
      try {
        this.loading = true;
        if (request.alreadySend) {
          request.cancel();
        }
        // 假装做了一个请求
        const data = request.doGet();
        this.data = data;
        return data
      } catch (e) {
        this.loading = false;
        this.data = null;
        throw e;
      }
    }
  }
}

并且在使用上与 mixin 并无差异,但是不论从 类型推导 上还是 使用 上都得到了巨大的提升,当然由于是公司的代码,这边大部分的工具方法还不适合展示。

但是从结果上来看,目前原本需要 2周 的任务,配合我们的 工具方法现有的组件库,使得开发周期最短可以缩减到 2~3天,这一提升是 巨大的。且 开发体验 相较之此前要 更好一点

举个简单的例子:

// 快速的创建一个请求
useRequest(xxxRequest, {});

// 快速的创建一个表格请求,它会自动的按照 filter(第二个参数)的变化去重新请求数据
const { dataSource, changePageInfo, pageInfo, loading } = useTable(getRecommendedPlanSummaryData, {
  type: computed(() => props.type),
  start_date: computed(() => props.start_date),
  end_date: computed(() => props.end_date),
});
// 同样的他上面的方法和参数可以直接绑定到我们的 table 上
<tds-table
  :columns="columns"
  :data-source="dataSource"
  :row-key="'date'"
  :loading="loading"
  :pagination="pageInfo"
  @pagination-change="changePageInfo"
/>

可能有的小伙伴还是不会认同在 vue2 中这样使用,但是我的观点还是建议可以试试,毕竟从 mixin 的写法迁移到 composition 本身 没有很大的成本

vuex?inject & provide 亦或是 pinia!

说起 composition 和整个开发流程,就不得不提提另外两个分支选项了,他们有一定的交集,但是也有一些不太一样的地方。

vuex

在长期的实践中,从我的观点来看,vuex 是一个很棒的状态流管理工具。

于此对应的是,过去的2年里面,vue 的中大型项目 大多 都会依托于他做 一个全局的状态管理

但是 vuex 本身也有若干的问题:

  • 类型提示不友好
  • 难以查找对应的方法(强依赖于 IDE 自动跳转和全局搜索)
  • 声明麻烦,使用麻烦

那么对于新的写法而言,我们去复用一个 store 在 vuex 的情况下是复杂,且不方便的。

const useStoreXXX  = () => {
  const vm = getCurrentInstance()?.proxy;
  // 这里没有任何提示,也不会校验 a 是否是可以访问的
  return computed(() => vm?.$store.state.a));
}

很多时候,我们对 全局的状态管理 的少量的,而 **对于一个 context 下(或者可以说是一个局部作用域)**的 状态管理大量的

而这在 vuex 中并不能很好的去使用、兼容、约束。

inject & provide?

我们可以把目光转向一个 vue2 中已经有的功能,那就是 inject & provide,他们提供了在一个父子层级下,统一的 “依赖注入” 方案(我个人喜欢用这种方式去理解,其在观感上同 DI 是一致的,细节上有所差异)。

那么结合 inject 和 provide 加上 vue 本身提供的响应式系统,我们可以很容易的实现一套 在某个作用域下 的局部 store,并且有着完善的类型提示 & 约束。

譬如:

const context1 = makeContext('data');
context1.provide('data2');
context1.inject();// 'data2'

const context2 = makeContextRef(() => ({ data: 1 }))
context2.provide(() => ({ data:2 }));
context2.inject();// Ref<{data:2}>

这在某种意义上提供了一个方便的方法去做一个更小粒度的 store、service 亦或是其他的用途。

当然 provide 和 inject 是危险的,我们应当 谨慎 的去使用,当然他们的作用也不止于此。

pinia

一个更新的选择是使用 pinia,当然我们还没正式开始用,这里就不多赘述了。至少按照官方维护者的说法,它本身有很大的可能会代替 vuex 成为 vue3 时代新的状态管理工具。

Pinia 🍍

tds-proto

tds-proto 是一个在 Q4 才纳入使用的解决方案,事实上早在 Q2 我已经试图和后端进行了一系列沟通,但是结局并不是很好。

当然从后端的角度也有 一定的意愿 使用类似的方案进行 一定程度 的改善,但这终究无法 在旧系统上 很好的运作。

在对于一套完整的旧系统,旧体系而言,任何方案都具有其 局限性适用范围。基于许多项目不同环境的考量,最终的实现也会有所差异,所以对此,我可以 理解并认可 这一事实。但如果有机会的话,我当然也希望可以改善这一困境。

最终,在同星云平台的后端交流时,最终大家一致通过并施行这一方案。那么下面我们来讲讲 为什么要这么做怎么做,以及有无其他扩展和类似的解决方案

前后端的痛苦对接

那么首先让我们回忆一下,我们的 API 对接有那些痛苦的往事。

对于一个常见的后端应用常见,我们首先回选择优先商定 API 接口,随后通过文档中心(如 confluence、yapi、api server 等)来共享文档定义。通常而言,后端比较喜欢的方式是利用 swagger 来生成对应自动文档、代码、和测试用例。

通常而言使用 swagger 的,比如 spring boot 或者 spring mvc 中(已经很久没写了,如有错误欢迎指出)只需要 简单使用注解 的方式即可完成某些 API 的定义。

但是这些 swagger 的 json 可不是能够自动上传到其他的文档管理中心的。通常而言,我们可以对接 ci 让 ci 自动的把这些 json 上传到文档展示中心。

而使用 swagger 最大的问题是,同 java 不同,许多其他语言并 不是所有语言 都是良好兼容 swagger 的(指可以通过扫描代码快速获得定义),这就导致文档的描述文件经常会和实际代码脱离

而另外一个问题就是 swagger 并不是一个 强约束,这带来的问题就是 API 接口和 swagger 对不上也完全可能

此外 swagger 带来的一个额外问题是 中间类型的丢失,其描述的 interface 类型局限于 单独的一个接口 而非存在 公共的中间类型 用于以 复用。(如果后端没有主动声明 Model 的话)

那么回归现实,假设这些都做好了会发生什么呢?

我们可以看到了即使后端可以通过 swagger 上传文档,仍然 无法减轻前端的工作量,也 无法减少维护文档的工作量。事实上最关键的一点是,即使通过 swagger ,我们也没有 统一前后端 API 定义的交流语言

而事实上 swagger 本身是 json、yaml 并不是完全人类阅读友好的,即使说有 ui ,前端对后端的定义也完全未知——他们是通过代码生成的,而这块的代码完全不会开放给前端,单纯的依赖 swagger 是没有意义的。

那么最关键的一点就是,我们有什么手段可以串联前后端的交流语言呢?甚至于说,如果有 一个良好的定义,是不是可以把文档都省了呢?

统一前后端交流语言

其实这是存在的,我们回头看看,在现今,2021年(现在都 2022 年了),gRPC 已经成为了后端远程调用的既定事实。

而在后端广大的语言中,不论是 go java php 亦或是 node 、scala 、kotlin 都有着 gRPC 对应的实现。

甚至于说如果不介意使用 gRPC-web,我们可以直接的串联整个前后端调用,将后端认作是前端的一个远程调用来去使用。

那么基于此 proto 就是我们最好的伙伴了。利用 proto 我们可以非常方便的强定义前后端交互的数据格式,接口等。

那么如果有了一个合适的构建器,那么我们可以方便的将流程转换为:

https://cdn.iceprosurface.com/image/2022-01-24/111329-VFIWrB.png

这一方式使得我们的前后端对接一下子变得极为简单,在结合 proto 进行的简单标注,可以方便的生成 mock 数据,最终配合我们的工具包可以快速的实现对接。

结合工具包生产 API 代码

我们可以结合并利用 apiService 快速的生成代码并对接

import { createApis } from '@taptap/tds-proto-main/tcc';
import { Request } from '@taptap/tds-tool-kit';
const ApiService = new Request(
  {
    baseURL: envApiBaseUrl,
    withCredentials: true,
  },
);
const TccApi = createApis(ApiService);

举个例子,如果要使用的话我们可以 非常方便 的使用:

const { 
  dataSource,
  changePageInfo, 
  pageInfo, 
  loading, 
  refresh
} = useProtoTable(TccAdmin.ListCreators, {
  mcn_id: mcnRef,
  search: searchRef,
});
// 和之前一样可以快速的绑定表格

// 快速的创建一个请求
const { 
  send: inviteCreator, 
  sending: inviteCreatorLoading,
  data,
} = useRequest(TccAdmin.InviteCreator);

// 这里的 data 从 proto 中直接获取类型

从实践来看,如果你是用 proto mock server 对接完成,那么直接同后端对接只需要 简单的核对 一下数据是否正确即可完成 全部对接。

原本我们需要花费大约 3-5天 的 API 对接时间缩减为最快当天 rnd (测试环境)上线即可使用。而其 数据对接准确性 也大大提升。

并且由于 proto 带来的强类型校验,一旦后端出现 api 的变动,将会直接反应到编译阶段,在编译期直接提示错误,也防止了双方因为 API 定义偏差可能导致的错误。

另一方面是其 api 的代码提示也非常良好,举例一些代码提示的例子(里面的 api 定义本身不敏感可以展示):

https://cdn.iceprosurface.com/image/2022-01-24/111657-iC0hSA.png

https://cdn.iceprosurface.com/image/2022-01-24/111714-xQwfx0.png

从上面可以看出,我们编写代码的时候,完全可以直接依赖 IDE 提示即可,这又是一个巨大的效率提升。

GraphQLRESTful 的碰撞?

有很多小伙伴一定会心生疑虑,既然完全重新实现了,为什么我们要用 RESTful 这么脱裤子放屁的活,前端最好的不是 GraphQL 么?为什么不用 GraphQL

首先,如果可以使用 GraphQL 当然是最好的,但是不见得我们对接的后端都支持 GraphQL,且我们也不可能要求星云的后端作为 API 网关让我们的 GraphQL 服务去对接第二方的 RESTful API。

另外一方面后端对前端的 GraphQL 语法并不感兴趣,他们内部统一的交流语言是 proto,而跨项目组交流的语言也是 proto。

此外,我并不认为 RESTful API 和 GraphQL冲突 的,本质 GraphQL 做的是一个 API 网关的事情,是一种 特化的 API 查询语言

如果整个后端有非常良好的 RESTful API 定义,他们是以 Open API 为标准对外输出的情况 下,实现 GraphQL 是 极其简单的。而从 0 开始直接使用 GraphQL ,强行的去实现,我认为这反而是 不妥的。

此外站在 星云平台 维护者的角度思考,我们对接的绝大部分 API 是 JSON API ,所以我甚至连 gRPC-web 都没有用上,仍然选择要求使用 JSON API 的方式传输,也是考虑接收来自各方 API 的情况下,不要对整体基础设施产生影响,这是站在工程角度的思考,而非技术角度

但从另外一方面来讲,从 “纯前端” 的技术角度思考,如果能使用 GraphQL 并要求各方从统一的 GraphQL API 网关 集中,并能 保证所有原子请求的速度理想 的情况下,我认为 GraphQL 确实是更好的选择

tds-ui-kit

UI Kit 本身其实最早是前端开始推行的,最后实践来看,反而是设计同学的速度要更快的一点。这当然也有业务本身的原因在里面。

我们大概有约 0.5 - 0.75 个人力可以花在这个上面,而月均维护时间不超过 2 - 4 个工作日。

也就是说,过去一年里面我们只有 18 个人天来维护整个组件库。

而对应设计至少有接近 1 - 1.25 个人力全职的维护,月均维护时间可以超过10个工作日以上,换算成人天,甚至可以达到 120 人天。这在时间上的 差距是巨大的

再者,在前端上的 边界复杂度 要远胜于设计稿上显示的,这带来的巨大差距使得我们完全不可能 对齐设计的开发效率

受迫于开发资源的紧张,我们只能选择通过既有的项目 (ant-design-vue)来做上层封装,同时尽可能的利用一切已有的功能向外做扩展。

做一个组件库并不容易

虽然平时嘴上不饶人的调侃着 “垃圾” ant-design-vue ,但是心底里还是觉得做这样一个组件库是相当不容易的,特别是自己开始维护一个内部组件库的时候。

同一般的库作者一样,我们很容易碰到的问题就是升级带来的问题。就像我老吐槽 vue3 相较之 vue2 数量巨大的 breaking changes

我们在对内需求(特别是 ui 变更带来的组件本身功能变更)的时候,真的是无法避免出现 breaking changes,这个时候怎么帮助各个项目组平滑的升级就变成了一个头秃的事情。

当然最好的还是能在立项之初就尽早的指出错误,及时止损才是最重要的。

另外就是在写组件库、公共组件的时候要时刻记住以下几点:

  • 一个 api 方法一旦开放,你将很难在回收这个 api
  • 谨慎的对外开放 内部方法,你永远不知道你的用户会用什么方式使用你的组件
  • 一定要按照最标准的方式实现,不要用任何黑科技、黑魔法

负负得正的包安装

在 2021 年的下半年对于 mono repo 的呼声越来越高,也涌现了许多不同的 mono repo 管理工具。而我们很早(2019年)就已经开始应用 mono ropo。所以对于一个内部的组件库,我们很自然的就选择了 mono 的方式去管理。

在当年,有且仅有 yarn + lerna 这套最终解决方案的情况下,我们直接用了他来维护,这也为后面的问题埋下了隐患。

从单包的使用来看,yarn 的体验已经非常好了,即使再快一倍,对于用户的感知也不强烈,所以无论从意愿和收益来看,更换 yarn 为其他包管理工具无益于项目。

但是,如果深度的在 workpacke 中(yarn 的 mono repo 管理方案),其对于公共依赖的安装一直都是有问题的,由于他主动打平依赖的问题,一旦出现某几个包依赖不同的版本(譬如 core-js@2 和 core-js@3)这样的情况,就会出现只安装其中一个包的情况(取决于 yarn.lock)。

这就导致了,在 workspace 情况下,使用往往就会出现了负负得正的奇怪情况 —— 也就是虽然包安装的是错误的,但是确实跑起来了,但是你未来需要更新,维护的时候,就突然发现:

我就改了一行 import 怎么整个项目炸了

这显然不是我们想要的于是我们在 Q3 计划逐渐迁移到其他对 workspace 兼容更好也更快的包管理工具上面。

yarn+lerna 亦或是 pnpm + changesets?

最后我们找到的是 pnpm,速度快,且对于 workspace 支持良好。

但是另外一个问题就是,我们之前普遍使用的版本管理工具 lerna 却对 pnpm 支持情况堪忧,基于此只能找一个替代方案,那就是 changesets。

但是事实上 changesets 远不如 lerna 好用。包括他的 changelog 的生产也远比 lerna 难用。希望在明年有更好的工具产生吧。

tds-icon

最早我们使用的 icon 是基于 ant 加的事实上用的就是 icon-font。这个东西好是好,就是每次复制粘贴很不开心。

事实上在我们用上 figma 以后并不需要这么麻烦因为上传一个图标是一个容易的事情,并且也可以非常方便的完成自动化。

figma 的帮助

figma 是有插件系统的,我们利用 figma 的插件系统非常简单的就实现了一个通用的上传插件:

https://cdn.iceprosurface.com/image/2022-01-24/111733-8M0WM5.png

基于这个插件就比较简单的完成了设计 icon → 前端 icon 的自动化

设计 icon 的自动化

我们非常简单的实现了一个 parser 将 svg 转换成各个框架支持的 icon,并约定了一些通用的规范。

https://cdn.iceprosurface.com/image/2022-01-24/111748-FA0mvK.png

随后需要解决的问题就是如何让设计加入这个流程,这个最简单的方法就是利用 gitlab 的 api 去差量的提交对应的 icon。

我写了一个比较丑的 ui 界面(能用就行,就花了半个下午,后续感谢来自社区 @黄同学 的支持):

首先配置参数

https://cdn.iceprosurface.com/image/2022-01-24/111809-p0n1Za.png

在设置工作空间以后,添加当前用户的 gitlab token,随后执行任务即可,插件会自行对比差异:

https://cdn.iceprosurface.com/image/2022-01-24/111845-AMbvTm.png

显示出来,最后提交后就会依次触发 pipeline ,然后发布 npm, 这个时候只需要前端简单更新一下版本即可。

至少目前使用下来还是很方便的。

同时我们制作了一个简单的预览平台可以查看、搜索这些 icon 的更新:

https://cdn.iceprosurface.com/image/2022-01-24/111858-8uD3CC.png

如何使用

首先 ui kit 为整个 tds-icon 提供了额外的功能,包括旋转等不同的特性,所以在 ui kit 注册 Icon 以后,只需要统一注册相关需要使用的 icon(当然不在乎大小的话你可以全部引入):

import { Icon } from '@taptap/tds-ui-kit';
import { LPlus } from '@taptap/tds-icon-vue';
Icon.setIconMap({
  LPlus,
});

直接使用图标下方的 名字 作为 type 字段,即可使用:

<tds-icon type="VS"/>

同样的如果想要按需引入,你也可以这样单独引入,由于使用了单例 wrapper 的特性,在注册外部组件后,所有按需单独引入的 icon 也会附带上 wrapper 组件的功能:

import { LPlus, Android } from '@taptap/tds-icon-vue';

总体使用下来还是很方便的,也非常容易支持按需加载。

tds-preset-config

最初,对于项目规范方面,我们是出了一套统一的规则,随后通过相关的 lint 工具来约束一些不规范的统一。

随着业务线的扩展,我们逐渐发现每次配置项目还是蛮麻烦的,随后就做了 seed 项目,给了一整组相关的预设参数,当然这不是最完善的方案。

工程统一

事实上,我们最终是希望达到所有工程(业务线)能够统一的,这包括附属的工具链,工具方法,依赖库等等,除开必要的依赖库维持统一以外,各业务本身应当可以做出一定程度上的弱扩展。

所以 tds-preset-config 就是为了这个目的去实现的,他同我们其他的项目一样使用了 mono repo 的方式去组织,并将各 lint 工具做了一份 preset 导出,事实上这一操作帮助我们在接入项目规范,去规范代码上节省的不少时间。

毕竟新的引入方式不需要安装各个包,随后在一个个配置,只需要 extend 对应的 preset ,在自己进行比较少量个性化配置即可。

未来工具链的畅想

但是事实上,仅仅这样做是不够的,核心在于我们还缺乏一系列必要的,统一的构建工具。

诚然,现有的工具 (vue-cli、vite)已经很优秀,且自由了,但是从项目的工程管理角度思考,他们太过自由。我们希望各个业务线至少在底层的实现上,并不要有太大的偏差,并且在对某一个业务线的工具上,提供了必要的支持以后可以快速横向的扩展到其他业务线,这是我们希望能比较好做的地方。

除此以外就是越来越多的工具库封装的需求,为了保证某块功能发版本后,不会互相影响。我们通常会采用 mono 的方式去组织,一般来讲对于一整个 mono 项目,我有很多通用方法(通用 rollup script 通用 gulp) 等方式去做统一的处理。

那么对于现在这个情况,更好的方式(暂劳永逸)就是去简单的封装一个略通用的构建工具去处理这些包(由于我们对 esm 包的要求是希望可以同 src 的基本一致,所以直接利用 vite lib 模式并不能非常好的处理)。

后记

过去的一年里面,好像做了很多,又好像没做很多,是一种比较奇妙的感觉 233。当然事实上有很多额外做的工作也没有在上面列出,总体这一年算是丰收的一年(搞定了不少问题)。

一年年下来,今年对于做成某些事情的感觉突然变得不是那么强烈了,或许是由于年龄逐渐大了,“丧失斗志” 了、亦或是过于守成了。

但是回过头来想想,我从十四五岁开始编程。在早年顶着小黑窗写个 C,痛骂着垃圾谭浩强。抓起 flash 写个小游戏。一路学了很多,看了很多,也做了很多。

一直到现在,在我十多年的编程生涯中,一直在逐渐的探索学习。

但是也就在最近的这一年里面才幡然醒悟过来,在过去,再遥远的过去,在最初的那个时刻 —— 我对于知识,往往报有着除开知识以外的目的。

他们或许是金钱、或许是权利、亦或是虚荣。然而事实上,直到今天才能领悟到,探索知识最广阔、最伟大的魅力是在于——随着时间的推移,这里繁杂的目的都会逐渐烟消云散。

而在这一片寂静中,萌发的是对于知识本身的热爱,而这份热爱推动这我一点点的向前。

本文标题:2021年年度总结

本文链接:https://iceprosurface.com/2021/11/25/2022/2021-annual-report/index.html

作者授权:除特别说明外,本文由 icepro 原创编译并授权刊载发布。

版权声明:本文使用「署名-非商业性使用-相同方式共享 4.0 国际」创作共享协议,转载或使用请遵守署名协议。