Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何编写高质量的函数 -- 命名/注释/鲁棒篇 #37

Open
godkun opened this issue Feb 26, 2019 · 0 comments
Open

如何编写高质量的函数 -- 命名/注释/鲁棒篇 #37

godkun opened this issue Feb 26, 2019 · 0 comments

Comments

@godkun
Copy link
Owner

godkun commented Feb 26, 2019

此篇我将会从函数的命名、注释和鲁棒性方面,阐述如何编写高质量的函数。

PS:这一篇相对上一篇文章要简单好懂多了。

引言

写第二篇之前,先说个事情。针对前面我写的
如何编写高质量的函数 -- 敲山震虎篇 文章的评论区,小伙伴提出的一些问题,我也全部都看了,特此写了答疑篇。

答疑篇放在了 issues 上,点击下面链接即可 TP :

如何编写高质量的函数 -- 敲山震虎的答疑篇

对我的回答有什么疑问的话,可以 issues 讨论,这篇文章就不放在掘金了。

PS: 提一下一些公众号(掘金,奇舞周刊等)转载的情况,由于文章一开始有错误,然后公众号又没有同步,然后我又不能帮你们改,于是点开公众号文章,看着那些还在的错误,却无能为力的心情,心里默默 ob : 感谢地主们的转载,那就这样吧😂。

好,胡诌不多说,直接开始吧。

函数命名

从文章开头的图片可以知道,命名和缓存是计算机科学中的两大难题。

而今天要说的是函数命名,虽然函数命名涉及到的范围较窄,但思想都是一样的,完全
可以借鉴到其他的形式中。

我阅读过代码大全的变量一章,也针对性的阅读过一些源码,比如 lodash , ramda 这些函数工具库。现在根据我个人的一些感悟,总结了一些我个人认为能帮助你彻底解决命名这个事情的 best practice

PS: 虽然变量命名这个没有所谓 best practice ,但是对于前端的函数命名来说,我个人认为是可以有一套完善的 best pratice 的, 听我娓娓道来。

目前前端的函数命名存在什么问题

存在什么问题呢?要我说啊,那些业界标准,比如驼峰,首字母大写的类和构造函数,下划线,$ 等都不是瓶颈。真正的瓶颈是一些你察觉不到的,或者察觉到但是无能无力的细节。比如:

  • 中英语言的差异性
  • 不懂得从多维度去提升命名的精确性
  • 不会使用辅助工具

下面进行简明扼要的分析。

PS: 关于驼峰等耳熟能详的业界标准我就不再提了。

汉语和英语的差异性

为什么一开始要说这个呢,因为我认为这是目前命名中,存在的最大的问题。英语水平好的哥们没多少,很多人不能掌握英语命名的那一套语法规则,说白了就是你英语水平达不到能像外国人那样能写出符合英语 style 的名字。

为什么不能用汉语方式命名呢?

理由就三个:

  • 用拼音的本质问题:汉语拼音存在多义性,想想就瑟瑟发抖。
  • 用汉字的问题:虽然辅助工具已经很完善了,但是没法普及,没法和国际接轨,说白了就一句话,太小众了,外国人没有会的,不跟你玩。
  • 鄙视链已经形成(emmmm)。

用英语时遇到的困难

最大的困难就是 不会

我举个例子,都知道 react 的生命周期吧,如下:

  • componentDidMount

  • componentWillReceiveProps

  • shouldComponentUpdate

很多人都会有疑问,为什么用 did ,为什么用 will 。行吧,记住就完事了,然后过了一段时间,面试的被问到了,然后心里 ob:componentMounted 还是啥来...

多么鲜活的例子,嗯,继续往下读吧,后面有惊(极)为(为)天(惊)人(悚)的答案。

如何让命名有英语 style

黑人脸,怎么让啊?

老哥,多翻翻高中或者初中的英语语法知识吧。比如我举个最简单的例子,你就知道了。

componentDidMountreact 等生命周期的钩子,但是为什么要这样命名?

componentWillReceiveProps 为什么要这样命名?

答案就在下图:

注意上图中的 did 代表一般过去时,will 代表一般将来时。

然后我们百科一般过去式和一般将来时,然后如图所示:

一般过去时:

一般将来时

看上图的红箭头,did 表示一般过去时,时是指动作发生的时间,用在这里,突出了钩子的含义,一旦 mount 成功就执行此函数。是不是瞬间明白了,好了,will 同理。

啥也别说了,赶紧去好好看看初高中英语语法吧。

通过函数返回结果来命名

这是个小特性,比如 shouldComponentUpdate , 为什么 should 放在最前面。

因为这个函数返回的值是布尔值。那么我们可以理解为这是个问句,通过问句的形式来告诉我们,这里具有不确定性,需要根据返回值来判断是否更新。

关于问句的英语语法,老铁们还要多翻翻语法书啊(泪)。

借助工具

借助谷歌翻译

谷歌翻译这个就不说了,大家都会

借助 codelf

这是一个神器,用来搜索各种开源项目中的变量命名,来给你提供参考。

地址:https://unbug.github.io/codelf/

对应名字的 VSCODE 插件也有,具体怎么用,小伙伴自行去了解吧。

如何避免函数命名的多义性和不可读性

可能你给一个函数命名了,其他人看到这个函数时,一脸懵逼,完全不知道这命名的啥子东西哟,只能靠猜。

比如我从 ramda 源码中找了一个函数,代码如下:

var forEachObjIndexed = _curry2(function forEachObjIndexed(fn, obj) {
  var keyList = keys(obj);
  var idx = 0;
  while (idx < keyList.length) {
    var key = keyList[idx];
    fn(obj[key], key, obj);
    idx += 1;
  }
  return obj;
});
export default forEachObjIndexed;

这个函数叫 forEachObjIndexed ,看到这个命名,是不是一脸懵逼,反正我第一次看到是懵逼了,这啥子嘛,什么鬼东西,然后我就去趴了下源码,从源码里面的函数注释中才知道是干啥的,函数注释如下图:

看到没,多详细,当然,这是为了输出文档用的,但是给了我们一起非常好的解决方法。那就是:

如果你实在想不到如何去命名,或者你自己已经知道这个命名很烂了,比如太长,比如很难理解,那这个时候你就别挣扎了。写一个你觉得还 ok 的命名,然后把剩下的时间留给你写注释吧。比如 forEachObjIndexed 的第一部分的注释就是对整个函数的整体介绍和说明。

如果你的函数命名很烂,那这个时候,函数的整体介绍和说明就显得非常重要了。这部分你一定要做好,英语水平不好的话,那就老老实实写中文。这部分做好了,这个函数你哪怕用两行文字命名的,或者用了火星文命名的,也没关系,问题不大。

函数命名的分类

最后说一说命名的分类,这是我个人的一些看法。

为什么我会说函数命名的分类呢,是因为我们经常会看到函数会这样命名(源码中很普遍),比如:

- $xxx()
- _xxx()

这种带各种前缀的函数名,看起来并不好看。这样命名,在我个人看起来是非常别扭的,但是为什么要有这种命名呢,其实这是前端的无奈之举。

核心原因就是 JS 语言不支持私有变量,导致只能使用 _ 或者 $ 来保证相应不可见对外不可见,通过治标不治本的方法来解决这个问题。

所以我把前端的函数命名分为两大类,如下:

  • 不想暴露给外部访问的函数(比如只给内部使用)
  • 暴露给外部访问的函数(各种功能方法)

我个人目前的观点,大致也就这两大类了。

PS:这里我没把 Symbol 初始化的函数命名考虑在内,比如如下代码:

const ADD = Symbol('add');

[ADD](a, b) {
  console.log('a + b')
}

关于 Symbol 的用法,大家可以自行了解,这种特例我就不考虑在内了。

PS:关于这个无奈之举,在了解的更多的时候,会发现在前端,并没有什么方法(设计模式也好,hack 方法也好)能绝对的解决上面的问题,所以有时候你不得不使用 _ 等,因为当都不能解决这个问题的时候,那越简单的方式越受欢迎,这就是现实。

总结

总结一下最佳实践:

多学习初中高中英语语法,开源项目中的函数命名没有那么难理解,通过语法的学习和借助工具,函数命名基本可以解决,如果遇到无法清晰描述所写函数的目的的命名时,请务必给函数写上良好注释,不管函数名字有多长多难懂,只要有良好的注释,那就是可以接受的一件事情。毕竟你也不想命名的这么难懂啊,但是能力有限,那就用汉语做好注释吧,这样的效果也是杠杠的。

如何通过良好的函数命名来提供函数的质量,我也说的差不多了,答案都在文字中,如何去借助工具,如何去理解英语中的命名语法,如何去通过多维度来增加命名含义的准确性和可读性。简单聊了下目前前端界函数命名的分类,大家自行体会和运用吧。

PS:一些都知道的点我就不说了,比如动词+名词,名词+动词,驼峰等,清晰描述函数的目的,这都不是痛点,痛点我都说了,最佳实践也说了。

函数的注释

我们来谈函数的注释,注释一方面提高了可读性,另一方面也可以通过注释去做一些其它的事情,比如生成在线文档。一个高质量的函数,注释少不了的,但是这并不代表所有的函数都需要注释。富有富的活法,穷有穷的潇洒,重要或者说复杂的函数,那就给个好注释,简单或者不重要的函数,可以不给注释或者给一个简单的注释。说空话没有意义,我们来看看目前函数的注释都有哪几种方式。

PS:这里要注意我上面的用词,如果你觉得这个函数命名很烂,别人看不到,那你就应该给一个好的注释。

先说一些有名的npm包的一些注释风格

就像大学里面写论文之前,都要阅读很多文献资料,我们也一样,我们来看看几个有名的 npm 包是怎么玩注释的。

egg.js 的注释风格

从图中,我们看到 egg.js 的入口文件的注释情况,暂且不去判断这是不是一种 doc 工具的注释规则(不要在意细节)。我们就看一下其注释特点,是不是发现和你脑海中的注释风格又有区别了呢。这种入口文件的注释特点,简单整洁,这种思想是不是要吸收一波,以后你做开源项目的时候,这些思想都可以带给你灵感。

继续看下图:

这是一个被抽象出来的基类,展示了作者 [Yiyu He] 当时写这个类的时候,其注释的风格。从这张图中,我们能学到什么呢?有以下几点:

第一点:构造函数的注释规则,表达式语句的注释规则。

第二点:注释的取舍,有一些变量可以不用注释,有些要注释,不要有那种要注释就要全部注释的思想。

再看个有趣的图片:

看上面两种图的箭头,指向的都是同一个作者 [fengmk2] , 我们看他的函数注释规则。体会一下不同,想想为什么第一张图没有空格,第二种有空格,还有对返回的 this 的注释,比如很多人习惯将 this 直接注释成 Object 类型。

lodash.js

说到函数注释,就不能不说到 lodash.js 。但是写到这,我发现这块要是加上去的话,第二篇的文字就又超了,那这里就不再说了,大家自己看看源码分析一下吧(这操作真香)。

通过注释生成在线文档的思考

有人说注释要很规范,方便给别人,比如用 jsdoc 等 。这里我个人的看法是这样的,对一些不需要开源的 web 项目,没有必要用 jsdoc , 理由如下:

  1. 繁琐,需要按照 jsdoc 规则来
  2. 个人认为,jsdoc 有入侵性,文档规则需要写在代码中。

这里我认为如果要写注释说明手册,对于大型项目,我推荐使用 apidoc , 因为 apidoc 入侵性不强,不要求把规则写在代码中,你可以把所有规则写到一个文件中。具体使用方法,我就不说了,自行搜索相关资料。

但是一般小项目,没有必要单独写一份 api 文档。如果是开源的大型项目,那你要考虑的事情就更多了,首先需要有开源的官方网站,你会看到网上的一些开源项目官网好像很酷,其实这个世界上不缺的就是轮子,你也可以很快的做出这样的网站,下面我们来看看是如何做到的。

首先我们看一下 taro 源码,会发现如下图:

这里就是生成一个静态网站的秘密,执行这个 npm run docs 就可以了。用到的是 docusaurus 包,不知道的可以自行搜索。

然后这里你看下图:

从图中可以知道,文档的内容,来源于 docs 目录,里面都是 md 文件,开源项目的文档说明都在这里。

当然也有把对应的文档直接放到对应的代码目录下的,比如 ant-design 如下图:

就是直接把文档放在组件目录下了。

从这里,我们可以知道,目前流行的开源项目的官方网站是怎么实现的,以及文档该怎么写。你可以要这和函数注释没有什么关系,但是想想好像又有点关系,这里就不多言了,自己体会吧。

我个人的注释习惯

下面说说我本人对函数注释(只针对函数注释)的一些个人风格或者意见。

分享 VSCode 关于注释的几个工具

  • Better Comments 给注释上色
  • Document This 自动生成注释
  • TODO Highlight 高亮 TODO ,并可以搜寻所有 TODO

具体用法就不说了,下面是一张演示图,自行去研究吧:


写和不写注释的平衡

我个人的观点是这样的:

不影响可读性,复杂度低,对外界没有过度干涉的函数可以不写注释。

表达式语句的注释

函数内,表达式语句的注释可以简单点,我一般如下图所示,// 后面加简要说明。

function add(a, b) {
  // sum ....
  let sum = a + b
}

TODO 注释

function say() {
  // TODO: 编写 say 具体内容
  console.log('say')
}

FIXME 注释

function fix() {
  // FIXME: 删除 console.log方法
  console.log('fix')
}

函数注释

一般我分为普通函数和构造函数。

普通函数注释:

/**
 * add
 * @param {Number} a - 数字
 * @param {Number} b - 数字
 * @returns {Number} result - 两个整数之和
 */
function add(a, b) {
  // FIXME: 这里要对 a, b 参数进行类型判断
  let result = a + b
  return (result)
}

构造函数注释:

class Kun {
  /**
   * @constructor
   * @param {Object} opt - 配置对象 
   */
  constructor(opt = {}) {
    // 语句注释
    this.config = opt
  }
}

总结

从开源项目的代码中可以发现,注释的风格多种多样,有时候我自己不同项目的注释风格也有点差别,但是我会尽可能的去平衡注释和不注释,上面注释的基本原则还是要遵守的。

但是怎么说呢,注释这块不存在银弹。

函数的鲁棒性(防御性编程)

大家都听过防御性编程对吧,let it crash 。 我们看一个段子,下图:

看最后一句,测试测了那么多场景,最后酒吧还是炸了(哈哈哈哈哈哈怎么回事?)。

所以,我们可以看出,防御性编程的核心就是:

把所有可能会出现的异常都考虑到,并且做相应处理。

但是我个人认为,防御性的程度要看其重要的程度。一般来说,不可能去处理所有情况的,但是提高代码鲁棒性的有效途径就是进行防御性的编程。

一个项目的思考

我接手过一个需求,重写(完全重构)苏宁易购微信小程序的登录注册绑定的功能,并将代码同步到苏宁其他小程序(和其他小程序的开发进行代码交接并协助 coder 平稳完成版本过渡)。

这个项目重要性不言而喻,由于用户的基数很大,风险程度很高,需要考虑很多场景,比如:

  1. 支不支持线上版本回退,也就是需要有前端的 AB 版本方案(线上有任何问题,可以快速切到旧登录方案)

  2. 需要有各种验证:图形验证码、短信验证码、ip 、人机、设备指纹、风控、各种异常处理、异常埋点上报等。

  3. 代码层面的考虑:通过代码优化,缩短总的响应时间,提高用户体验。

  4. 如何确保单个节点出问题,不会影响整个登录流程。

你会发现,需要考虑的点很多,如何去合理的完成这个需求还是比较有难度的。

PS: 关于第四点的如何确保单个节点出问题,不会影响整个登录流程,文末有答案。

下面我就关于函数鲁棒性,说一说我个人的一些看法。

前端函数鲁棒性的几种方式

入参要鲁棒性

ES6 的到来后,函数的入参写法已经得到了质的提高和优化。看下面代码

function print(obj = {}) {
  console.log('name', obj.name)
  console.log('age', obj.age)
}

print 函数,入参是 obj 通过 obj = {} 来给入参设置默认的参数值,从而提高入参的鲁棒性。

但是你会发现,如果入参的默认值是 {} ,那函数里面的 obj.name 就会是 undefined ,这也不够鲁棒,所以下面就要说说函数内表达式语句的鲁棒性了。

函数内表达式语句要鲁棒性

继续上个例子:

function print(obj = {}) {
  console.log('name:', obj.name || '未知姓名')
  console.log('age:', obj.age || '未知年龄')
}

如果这样的话,那你会发现表达式语句就变得比较鲁棒性了,但是还不够好,这样写不够抽象,我们换种方式稍微把表达式语句给解耦一下,代码如下:

function print(obj = {}) {
  const { name = '未知姓名', age = '未知年龄' } = obj
  console.log('name:', name)
  console.log('age:', age)
}

这样的话,看起来就感觉好多了,其实还可以再抽象,比如吧 console.log 封装成 log 函数,通过调用 log(name) ,就能完成 console.log('name:', name) 的功能, 这里就不再说了,自行研究吧。

函数异常处理的两个层面

上面的那几个点,我个人认为可以归类到一个方案层面去,那就是:

防患于未然,从一开始就不要让异常发生。

但是不要忘了,总会有万一,还有一个方案层面要去考虑,那就是:

异常还是出现了,该怎么去处理出现的异常。

下面两个层面已经确定了,那如何去更好的处理各种异常,提高函数的鲁棒性呢,我个人有以下几点看法。

推导一下 try/catch 的原理

有很多人不清楚怎么去用 try/catch 。这里我来按照我个人的见解,来推一下其原理吧,首先 js 是运行在 node.js 提供的运行时环境中的,而 node.js 是用 C++ 写的。C++ 是有自己的异常处理机制的,也是有 try/catch 的 。那就说明 jstry/catch 的底层实现是直接通过桥,调用 C++try/catch

C++try/catch 具有的一些特性,大家可以自行去了解一下,比如其中就有一个特性是这样的:

try/catch 只能捕捉当前线程的异常。

所以这也就很好的解释了,为什么 JStry/catch 只能捕捉到同步的异常,而对于异步的异常就无能为力了(因为异步是放在另一个线程中执行的)。

这里是我的推导,不代表确切答案。

这里我推荐一篇博客:

C++中try、catch 异常处理机制

有兴趣的可以看看。

什么情况下需要考虑异常处理-->

合理的处理异常

这里有几个方法:

第一个方法:如果是同步的操作,可以用 throw 来传递异常

看下面代码:

try {
  throw new Error('hello godkun, i am an Error ')
  console.log('throw 之后的处代码不执行')
} catch (e) {
  console.log(e.message)
}

首先我们要知道 throw 是以同步的方式传递异常的,也就是 throw 要和使用 throw 传递错误的函数拥有相同的上下文环境。

如果上下文环境中,都没有使用 try/catch 的话,但是有 throw 了异常,那么程序大概率会崩溃。

如果是 nodejs ,这个时候就应该再加一个进程级的 uncaughtException 和 来捕捉各种这种没有被捕捉的异常。通常还会加上 unhandledRejection 的异常处理。

第二个方法:如果是异步的操作

有三种方式:

  1. 使用callback ,比如 nodejserror first 风格

  2. 对于复杂的情况可以使用基于 Event 的方式来做,调用者来监听对象的 error 事件

  3. 使用 promiseasync/await 来捕捉异常

现在的问题是,怎么去选择哪个方式呢?有这几个原则:

  1. 简单的场景,直接使用 promiseasync/await 来捕捉异常

  2. 复杂的场景,比如可能会产生多个错误,这个时候最好用 Event 的方式

第三个方法:如果既有异步操作又有同步操作

怎么办呢?这个时候,我个人认为,最好的方式,就是使用最新的语法:async/await 来结合 promisetry/catch 来完成对既有同步操作又有异步操作的异常捕捉。

第四个方法:处理异常的一些抽象和封装

对处理异常的函数进行抽象和封装也是提高函数质量的一个途径。如何对处理异常进行抽象和封装呢?有几个方式可以搞定它:

  • 第一个方式:对 nodejs 来说,通常将异常处理封装成中间件,比如基于 express/koa 的异常中间件,通常情况下,处理异常的中间件要作为最后一个中间件加载,目的是为了捕获之前的所有中间件可能出现的错误

  • 第二个方式:对前端或者 nodejs 来说,可以将异常处理封装成模块,类似 Event 的那种。

  • 第三种方式:使用装饰器模式,对函数装饰异常处理模块,比如通过装饰器把当前函数在包裹一层 try/catch

  • 第四种方式:使用函数式编程中的函子( Monad )等来对异常处理进行统一包裹,这里 的 Manadtry/catch 在表现上都相当于一个容器,这是一个相当强大的方法,从 Monad 可以扩展出很多异常处理的黑科技,但是我建议慎用,因为不是所有人都能看懂的,要考虑团队的整体技术能力,当然一个人的话,那就随便嗨了。

合理的处理异常,需要能确定使用哪一种方式来处理异常,我大致也说了具体的选择情况,这里我推荐一篇博客:

Callback Promise Generator Async-Await 和异常处理的演进

目前我见到的讲的最全的处理异常的博客,但我这里说的都是我认为比较重要的主要的点,两者还是有明显区别的,大家融合一下吸收吸收吧。

如何确保单个节点出问题,不会影响整个登录流程

比如登录流程需要4个安全验证,按照通常的写法,其中一个挂了,那就全部挂了,但是这不够鲁棒性,如何去解决这个问题呢。这里我提一下,可能很多人都不会注意到。

主要方案就使用将 promise 的链式写法换一种方式写,以前的写法是这样的:

伪代码如下:

auth().then(getIP).then(getToken).then(autoLogin).then(xxx).catch(function(){})

经过鲁棒调整后,可以改成如下写法:

伪代码如下:

auth().catch(goAuthErrorHandle).then(getIP).catch(goIPErrorHandle).then(function(r){})

经过微调后的代码,直接让登录流程的鲁棒性提升了很多,就算出错也可以通过错误处理后,继续传递到下一个方法中。

我个人对异常处理的看法

我个人认为对异常的处理,还是要根据实际情况来合理的。大概有以下几点看法:

要考虑项目可维护性,团队技术水平

我曾在一个需求中,使用了诸如函子等较为抽象的处理异常的方法,虽然秀了一把(作死),结果导致,后续这块的需求改动,还得我自己来。嗯,就是这么刺激,因为同事不熟悉函数式编程。

要提前预估好项目的复杂性和重要性。

比如在做一个比较重要的业务时,一开始没有想到异常处理需要这么细节,而且一般第一版的时候,需求并没有涉及到很多异常情况处理,但是后续需求迭代优化的时候,发现异常情况处理是如此的多,直接导致需要重写异常处理相关的代码。

所以以后在项目评估的时候,要学会去尝试根据项目的重要性,来提前预留好坑位。

这也算是一种面对未来的编程模式。

总结

关于函数的鲁棒性(防御性编程),我介绍了很多东西,基本上是前端或者是 nodejs 处理异常的常规方法吧。处理异常不是一个简单的活,工作中还得结合业务去确定合适的异常处理方式,总之,多多实践出真知吧。

备注

  • 本篇文章阅读起来就比较轻松了,难度不大,细细体会一下会有一些收获的。
  • 可能讲解的不全,如果有什么遗漏的,欢迎在评论区分享,一起进步。
  • 鲁棒性这块,我没有提单元测试,字数不够写了,再写文章又很长了,单元测试我只推荐 Jest ,按照官网文档来,本着函数式编程的思想,问题不大。

往届精品文章

可以这么说,读了这篇文章,你的 gitgerrit 就没有什么问题。

初中高级的 git 和 gerrit 技巧【大型项目实战总结 && CR 经验】


关于如何阅读 npm 包源码的故事。

不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

彩蛋

最近刚写的一篇文章,发现没什么阅读量,但是我觉得我写的很好啊,这篇文章绝对会对绝大多数前端工程师有所启发和帮助。

宣传一波,想启发更多还在路上的前端小伙伴:

新时代下前端工程师的推荐书籍和必备知识

交流

加上本篇,这个系列已经写了两篇了,我计划三篇搞定,但是看这情况,后面都是硬货,很难 7000 字以内搞定呀,后续几篇搞定,我再 ob 一下吧。

小伙伴们可以关注我的掘金博客或者 github 来获取后续的系列文章更新通知。

掘金系列技术文章在 github 上汇总如下,觉得不错的话,点个 star 鼓励一下,我将 ** 使命必达** ,持续输出精品文章。

https://github.com/godkun/blog

我是源码终结者,欢迎技术交流。

也可以进 前端狂想录群 大家一起头脑风暴。有想加的,因为人满了,可以先加我好友,我来邀请你进群。

风之语

最后:尊重原创,转载请注明出处哈😋

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant