【技术思路】极客时间-左耳听风-开篇词2
11 | 程序中的错误处理:错误返回码和异常捕捉
传统的错误检查
- 传统的方式:处理错误最直接的方式是通过错误码。
多返回值
于是,有一些语言通过多返回值来解决这个问题,比如 Go 语言。Go 语言的很多函数都会返回result, err
两个值。
-
参数上基本上就是入参,而返回接口把结果和错误分离,这样使得函数的接口语义清晰;
-
Go 语言中的错误参数如果要忽略,需要显式地忽略,用
_
这样的变量来忽略; -
因为返回的
error
是个接口(其中只有一个方法Error()
,返回一个string
),所以可以扩展自定义的错误处理。
资源清理
- C语言方式:
这样的处理方式虽然可以,但是会有潜在的问题。最主要的一个问题就是你不能在中间的代码中有 return
语句,因为你需要清理资源。在维护这样的代码时需要格外小心,因为一不注意就会导致代码有资源泄漏的问题。
#define FREE(p) if(p) { \
free(p); \
p = NULL; \
}
main()
{
char *fname=NULL, *lname=NULL, *mname=NULL;
fname = ( char* ) calloc ( 20, sizeof(char) );
if ( fname == NULL ){
goto fail;
}
lname = ( char* ) calloc ( 20, sizeof(char) );
if ( lname == NULL ){
goto fail;
}
mname = ( char* ) calloc ( 20, sizeof(char) );
if ( mname == NULL ){
goto fail;
}
......
fail:
FREE(fname);
FREE(lname);
FREE(mname);
ReportError(ERR_NO_MEMORY);
}
- C++方式
C++ 的 RAII(Resource Acquisition Is Initialization)机制使用面向对象的特性可以容易地处理这个事情。RAII 使用 C++ 类的机制,在构造函数中分配资源,在析构函数中释放资源。
// 首先,先声明一个 RAII 类,注意其中的构造函数和析构函数
class LockGuard {
public:
LockGuard(std::mutex &m):_m(m) { m.lock(); }
~LockGuard() { m. unlock(); }
private:
std::mutex& _m;
}
// 然后,我们来看一下,怎样使用的
void good()
{
LockGuard lg(m); // RAII 类:构造时,互斥量请求加锁
f(); // 若 f() 抛异常,则释放互斥
if(!everything_ok()) return; // 提早返回,LockGuard 析构时,互斥量被释放
} // 若 good() 正常返回,则释放互斥
- Go语言
在 Go 语言中,使用defer
关键字也可以做到这样的效果:
func Close(c io.Closer) {
err := c.Close()
if err != nil {
log.Fatal(err)
}
}
func main() {
r, err := Open("a")
if err != nil {
log.Fatalf("error opening 'a'\n")
}
defer Close(r) // 使用 defer 关键字在函数退出时关闭文件。
r, err = Open("b")
if err != nil {
log.Fatalf("error opening 'b'\n")
}
defer Close(r) // 使用 defer 关键字在函数退出时关闭文件。
}
异常捕捉处理
try - catch - finally
编程模式。
try {
... // 正常的业务代码
} catch (Exception1 e) {
... // 处理异常 Exception1 的代码
} catch (Exception2 e) {
... // 处理异常 Exception2 的代码
} finally {
... // 资源清理的代码
}
这样的异常处理方式有如下一些好处。
- 函数接口在 input(参数)和 output(返回值)以及错误处理的语义是比较清楚的。
- 正常逻辑的代码可以与错误处理和资源清理的代码分开,提高了代码的可读性。
- 异常不能被忽略(如果要忽略也需要 catch 住,这是显式忽略)。
- 在面向对象的语言中(如 Java),异常是个对象,所以,可以实现多态式的 catch。
- 与状态返回码相比,异常捕捉有一个显著的好处是,函数可以嵌套调用,或是链式调用。比如:
int x = add(a, div(b,c));
或Pizza p = PizzaBuilder().SetSize(sz) .SetPrice(p)...;
但有个致命的问题,那就是在异步运行的世界里的问题。try 语句块里的函数运行在另外一个线程中,其中抛出的异常无法在调用者的这个线程中被捕捉。
错误返回码 vs 异常捕捉
前面也比较过两者的优缺点,总体而言,似乎异常捕捉的优势更多一些。应该从场景上来讨论这个事才是正确的姿势。错误可分为三个大类:
-
资源的错误:程序运行环境的问题,代码去请求一些资源时导致的错误,比如打开一个没有权限的文件,写文件时出现的写错误,发送文件到网络端发现网络故障等错误。关键性资源不能满足的情况,内存耗尽、栈溢出、一些程序运行时关键性资源不能满足等。
-
程序的错误:比如:空指针、非法参数等。这类是自己程序的错误,要记录下来,写入日志,最好触发监控系统报警
-
用户的错误:在用户的API层上出现的问题,比如解析一个XML和JSON文件,或是用户输入的字段不合法类的错误。
可以这样来在逻辑上分类:
- 对于我们并不期望会发生的事,我们可以使用异常捕捉;
- 对于我们觉得可能会发生的事,使用返回码。
12 | 程序中的错误处理:异步编程以及我的最佳实践
异步编程世界里的错误处理
在异步编程的世界里,因为被调用的函数是被放到了另外一个线程里运行,这将导致:
- 无法使用返回码:因为函数在异步状态中,只是把返回的处理权交给下一条指令,而不是把函数运行完的结果返回。
- 无法使用抛异常的方式:因为除了上述的函数立马返回的原因之外,抛出的异常也在另外一个线程中,不同线程中的栈是完全不一样的,所以主线程的
catch
完全看不到另外一个线程中的异常。
几种处理错误的方法:
对此,在异步编程的世界里,我们也会有好几种处理错误的方法,最常用的就是callback
方式。在做异步请求的时候,注册几个 OnSuccess()
、OnFailure()
的函数,让在另一个线程中运行的异步代码来回调回来。
JavaScript 异步编程的错误处理
function successCallback(result) {
console.log("It succeeded with " + result);
}
function failureCallback(error) {
console.log("It failed with " + error);
}
doSomething(successCallback, failureCallback);
通过注册错误处理的回调函数,让异步执行的函数在出错的时候,调用被注册进来的错误处理函数,这样的方式比较好地解决了程序的错误处理。而出错的语义从返回码、异常捕捉到了直接耦合错误出处函数的样子。
Java 异步编程的 Promise 模式
在 Java 中,在 JDK 1.8 里也引入了类似 JavaScript 的玩法 —— CompletableFuture
。
链式处理:
CompletableFuture.supplyAsync(this::findReceiver)
.thenApply(this::sendMsg)
.thenAccept(this::notify);
Go 语言的 Promise
首先,先声明一个结构体。其中有三个成员:第一个 wg
用于多线程同步;第二个 res
用于存放执行结果;第三个 err
用于存放相关的错误。
type Promise struct {
wg sync.WaitGroup
res string
err error
}
然后,定义一个初始函数,来初始化 Promise 对象。其中可以看到,需要把一个函数 f
传进来,然后调用 wg.Add(1)
对 waitGroup 做加一操作,新开一个 Goroutine 通过异步去执行用户传入的函数 f()
,然后记录这个函数的成功或错误,并把 waitGroup 做减一操作。
func NewPromise(f func() (string, error)) *Promise {
p := &Promise{}
p.wg.Add(1)
go func() {
p.res, p.err = f()
p.wg.Done()
}()
return p
}
然后需要定义 Promise 的 Then 方法。其中需要传入一个函数,以及一个错误处理的函数。并且调用 wg.Wait()
方法来阻塞(因为之前被wg.Add(1)
),一旦上一个方法被调用了 wg.Done()
,这个 Then 方法就会被唤醒。
唤醒的第一件事是,检查一下之前的方法有没有错误。如果有,那么就调用错误处理函数。如果之前成功了,就把之前的结果以参数的方式传入到下一个函数中。
func (p *Promise) Then(r func(string), e func(error)) (*Promise){
go func() {
p.wg.Wait()
if p.err != nil {
e(p.err)
return
}
r(p.res)
}()
return p
}
下面,定义一个用于测试的异步方法。这个方面很简单,就是在数数,然后,有一半的机率会出错。
func exampleTicker() (string, error) {
for i := 0; i < 3; i++ {
fmt.Println(i)
<-time.Tick(time.Second * 1)
}
rand.Seed(time.Now().UTC().UnixNano())
r:=rand.Intn(100)%2
fmt.Println(r)
if r != 0 {
return "hello, world", nil
} else {
return "", fmt.Errorf("error")
}
}
下面,来看看实现的 Go 语言 Promise 是怎么使用的。
func main() {
doneChan := make(chan int)
var p = NewPromise(exampleTicker)
p.Then(func(result string) { fmt.Println(result); doneChan <- 1 },
func(err error) { fmt.Println(err); doneChan <-1 })
<-doneChan
}
错误处理的最佳实践
-
统一分类的错误字典:建立一个错误字典,无论是使用错误码还是异常捕捉,都需要认真并统一地做好错误的分类。最好是在一个地方定义相关的错误。比如,HTTP 的 4XX 表示客户端有问题,5XX 则表示服务端有问题。
-
同类错误的定义最好是可以扩展的:通过面向对象的继承或是像 Go 语言那样的接口多态可以很好重用已有的代码。
-
定义错误的严重程度:比如Fatal 表示重大错误,Error 表示资源或需求得不到满足,Warning 表示并不一定是个错误但还是需要引起注意,Info 表示不是错误只是一个信息,Debug 表示这是给内部开发人员用于调试程序的。
-
错误日志的输出最好使用错误码,而不是错误信息:打印错误日志的时候,应该使用统一的格式。但最好不要用错误信息,而应使用相应的错误码,错误码不一定是数字,也可以是一个能从错误字典里找到的一个唯一的可以让人读懂的关键字。这样会非常有利于日志分析软件进行自动化监控,而不是要从错误信息中做语义分析。比如:HTTP 的日志中就会有 HTTP 的返回码,如:
404
。使用PageNotFound
这样的标识,这样人和机器都很容易处理。 -
忽略错误最好有日志:不然会给维护带来很大的麻烦。
-
对于同一个地方不停的报错,最好不要都打到日志里:不然这样会导致其它日志被淹没了,也会导致日志文件太大。最好的实践是,打出一个错误以及出现的次数。
-
不要用错误处理逻辑来处理业务逻辑:不要使用异常捕捉这样的方式来处理业务逻辑,而是应该用条件判断。如果一个逻辑控制可以用 if - else 清楚地表达,那就不建议使用异常方式处理。异常捕捉是用来处理不期望发生的事情,而错误码则用来处理可能会发生的事。
-
对于同类的错误处理,用一样的模式:比如,对于
null
对象的错误,要么都用返回 null,加上条件检查的模式,要么都用抛 NullPointerException 的方式处理。不要混用,这样有助于代码规范。 -
尽可能在错误发生的地方处理错误:因为这样会让调用者变得更简单。
-
向上尽可能地返回原始的错误:如果一定要把错误返回到更高层去处理,那么应该返回原始的错误,而不是重新发明一个错误。
-
处理错误时,总是要清理已分配的资源:使用 RAII 技术,或是 try-catch-finally,或是 Go 的 defer 都可以容易地做到。
-
不推荐在循环体里处理错误:这里说的是 try-catch,绝大多数的情况你不需要这样做。最好把整个循环体外放在 try 语句块内,而在外面做 catch。
-
不要把大量的代码都放在一个 try 语句块内:一个 try 语句块内的语句应该是完成一个简单单一的事情。
-
为你的错误定义提供清楚的文档以及每种错误的代码示例:如果你是做 RESTful API 方面的,使用 Swagger 会帮你很容易搞定这个事。
-
对于异步的方式,推荐使用 Promise 模式处理错误:对于这一点,JavaScript 中有很好的实践。
-
对于分布式的系统,推荐使用 APM 相关的软件:尤其是使用 Zipkin 这样的分布式跟踪系统来帮助收集时间数据关联错误。
13 | 魔数 0x5f3759df
数学的基础知识。数学真是需要努力学习好的一门功课,尤其在人工智能火热的今天。
但是对于系统化学习追求者来说,这篇文章并不具备一定的视野价值。
略
14 | 推荐阅读:机器学习101
在机器学习(Machine learning)领域,主要有三类不同的学习方法:
监督学习(Supervised learning)、
非监督学习(Unsupervised learning)、
半监督学习(Semi-supervised learning),
监督学习:通过已有的一部分输入数据与输出数据之间的对应关系,生成一个函数,将输入映射到合适的输出,例如分类。
非监督学习:直接对输入数据集进行建模,例如聚类。
半监督学习:综合利用有类标的数据和没有类标的数据,来生成合适的分类函数。
文章中作者描述机器学习主要来说有两种方法,监督式学习(Supervised Learning)和非监督式学习。
监督式学习
提供一组学习样本,包括相关的特征数据以及相应的标签。程序可以通过这组样本来学习相关的规律或是模式,然后通过得到的规律或模式来判断没有被打过标签的数据是什么样的数据。
在监督式学习下,需要有样本数据或是历史数据来进行学习,这种方式会有一些问题。比如
- 如果一个事物没有历史数据,那么就不好做了。变通的解决方式是通过一个和其类似事物的历史数据。我以前做过的需求预测,就属于这种情况。对于新上市的商品来说,完全没有历史数据,比如,iPhone X,那么就需要从其类似的商品上找历史数据,如 iPhone 7 或是别的智能手机。
- 历史数据中可能会有一些是噪音数据,需要把这些噪音数据给过滤掉。一般这样的过滤方式要通过人工判断和标注。举两个例子,某名人在其微博或是演讲上推荐了一本书,于是这本书的销量就上升了。这段时间的历史数据不是规律性的,所以就不能成为样本数据,需要去掉。同样,如果某名人(如 Michael Jackson)去世导致和其有关的商品销售量很好,那么,这个事件所产生的数据则不属于噪音数据。因为每年这个名人忌日的时候出现销量上升的可能性非常高,所以,需要标注一下,这是有规律的样本,可以放入样本进行学习。
非监督式学习
对于非监督式学习,也就是说,数据是没有被标注过的,所以相关的机器学习算法需要找到这些数据中的共性。因为大量的数据是没有被标识过的,所以这种学习方式可以让大量未标识的数据能够更有价值。
比如,一个在某一年龄段区间的女生购买了某种肥皂,有可能说明这个女生在怀孕期,或是某人购买儿童用品,有可能说明这个人的关系链中有孩子,等等。于是这些信息会被用作一些所谓的精准市场营销活动,从而可以增加商品销量。
在监督式的学习的算法下,我们可以用一组“狗”的照片来确定某个照片中的物体是不是狗。而在非监督式的学习算法下,我们可以通过一个照片来找到与其相似事物的照片。这两种学习方式都有各自适用的场景。
如何找到数据的规律和关联
机器学习基本就是在已知的样本数据中寻找数据的规律,在未知的数据中找数据的关系。
机器学习中的基本方法论
- 要找到数据中的规律,你需要找到数据中的特征点。
- 把特征点抽象成数学中的向量,也就是所谓的坐标轴。一个复杂的学习可能会有成十上百的坐标轴。
- 抽象成数学向量后,就可以通过某种数学公式来表达这类数据(就像 y=ax+b 是直线的公式),这就是数据建模。
机器学习文章:
Machine Learning is Fun!
https://medium.com/@ageitgey/machine-learning-is-fun-80ea3ec3c471
中文翻译版
https://zhuanlan.zhihu.com/p/24339995
相关算法
对于监督式学习,有如下经典算法。
- 决策树(Decision Tree)。比如自动化放贷、风控。
- 朴素贝叶斯分类(Naive Bayesian classification)。可以用于判断垃圾邮件,对新闻的类别进行分类,比如科技、政治、运动,判断文本表达的感情是积极的还是消极的,以及人脸识别等。
- 最小二乘法(Ordinary Least Squares Regression)。算是一种线性回归。
- 逻辑回归(Logisitic Regression)。一种强大的统计学方法,可以用一个或多个变量来表示一个二项式结果。它可以用于信用评分、计算营销活动的成功率、预测某个产品的收入等。
- 支持向量机(Support Vector Machine,SVM)。可以用于基于图像的性别检测,图像分类等。
- 集成方法(Ensemble methods)。通过构建一组分类器,然后根据它们的预测结果进行加权投票来对新的数据点进行分类。原始的集成方法是贝叶斯平均,但是最近的算法包括纠错输出编码、Bagging 和 Boosting。
对于非监督式的学习,有如下经典算法。
- 聚类算法(Clustering Algorithms)。聚类算法有很多,目标是给数据分类。
- 主成分分析(Principal Component Analysis,PCA)。PCA 的一些应用包括压缩、简化数据,便于学习和可视化等。
- 奇异值分解(Singular Value Decomposition,SVD)。实际上,PCA 是 SVD 的一个简单应用。在计算机视觉中,第一个人脸识别算法使用 PCA 和 SVD 来将面部表示为“特征面”的线性组合,进行降维,然后通过简单的方法将面部匹配到身份。虽然现代方法更复杂,但很多方面仍然依赖于类似的技术。
- 独立成分分析(Independent Component Analysis,ICA)。ICA 是一种统计技术,主要用于揭示随机变量、测量值或信号集中的隐藏因素。
上面的这些相关算法来源自博文《The 10 Algorithms Machine Learning Engineers Need to Know》。
https://www.kdnuggets.com/2016/08/10-algorithms-machine-learning-engineers.html
相关推荐
学习机器学习有几个课是必须要上的,具体如下。
-
吴恩达教授(Andrew Ng)在Coursera 上的机器学习课程,网易公开课上也有该课程http://open.163.com/special/opencourse/machinelearning.html
-
卡内基梅隆大学计算机科学学院汤姆·米切尔(Tom Mitchell)教授的机器学习课程,这里有英文原版视频和课件 PDF。《机器学习》
-
加利福尼亚理工学院亚瑟·阿布·穆斯塔法(Yaser Abu-Mostafa)教授的 Learning from Data 系列课程
除了上述的那些课程外,下面这些资源也很不错。
-
YouTube 上的 Google Developers 的 Machine Learning Recipes with Josh Gordon
-
Practical Machine Learning Tutorial with Python Introduction
-
Medium 上的 Machine Learning - 101
-
关于 Deep Learning 中神经网络的学习,推荐 YouTube 介绍视频 Neural Networks
值得翻阅的图书。
- 《机器学习》,南京大学周志华教授著。它是一本机器学习方面的入门级教科书,适合本科三年级以上的学生学习。
- A Course In Machine Learning,马里兰大学哈尔·道姆(Hal Daumé III)副教授著。 这本书讲述了几种经典机器学习算法,包括决策树、感知器神经元、kNN 算法、K-means 聚类算法、各种线性模型(包括对梯度下降、支持向量机等的介绍)、概率建模、神经网络、非监督学习等很多主题,还讲了各种算法使用时的经验技巧,适合初学者学习。此外,官网还提供了免费电子版。
- Deep Learning,麻省理工学院伊恩·古德费洛(Ian Goodfellow)、友华·本吉奥(Yoshua Benjio)和亚伦·考维尔(Aaron Courville)著。这本书是深度学习专题的经典图书。它从历史的角度,将读者带进深度学习的世界。深度学习使用多层的(深度的)神经元网络,通过梯度下降算法来实现机器学习,对于监督式和非监督式学习都有大量应用。如果读者对该领域有兴趣,可以深入阅读本书。本书官网提供免费电子版,但不提供下载。实体书(英文原版或中文翻译版)可以在网上买到。
- Reinforcement Learning,安德鲁·巴托(Andrew G.Barto)和理查德·萨顿(Richard S. Sutton)著。这本书是强化学习(Reinforcement Learning)方面的入门书。它覆盖了马尔可夫决策过程(MDP)、Q-Learning、Sarsa、TD-Lamda 等方面。这本书的作者是强化学习方面的创始人之一。强化学习(结合深度学习)在围棋程序 AlphaGo 和自动驾驶等方面都有着重要的应用。
- Pattern Recognition and Machine Learning ,微软剑桥研究院克里斯托夫·比肖普(Christoph M. Bishop)著。这本书讲述了模式识别的技术,包括机器学习在模式识别中的应用。模式识别在图像识别、自然语言处理、控制论等多个领域都有应用。日常生活中扫描仪的 OCR、平板或手机的手写输入等都属于该领域的研究。
15 | 时间管理:同扭曲时间的事儿抗争
主动管理
- 你要主动管理的不是你的时间,而是管理你的同事,管理你的信息
仿照一下以前在 Outlook 里设置工作日程的方式,把你的工作安排预先设置到一个可以共享的日历上,然后分享给大家,让大家了解你的日程。
学会说“不”
- 给出另一个你可以做到的方案,而不是把对方的方案直接回绝掉
- 我不说我不能完全满足你,但我说我可以部分满足你
我不能说不,但是我要有条件地说是。而且,我要把你给我的压力再反过来还给你,看似我给了需求方选择,实际上,我掌握了主动。
“积极主动的态度下对于不合理的事讨价还价”。只有学会了说“不”,你才能够控制好你的时间。
加班和开会
开会,不是讨论问题,而是讨论方案,开会不是要有议题,而是要有议案。
16 | 时间管理:如何利用好自己的时间?
投资自己的时间
- 花时间学习基础知识,花时间读文档:系统地学习一门技术是非常关键的,所以这个时间是值得投资的。
- 花时间在解放自己生产力的事上:花时间在解放自己的事上是最有意义的了
- 花时间在让自己成长的事上
- 花时间在建立高效的环境上
规划自己的时间
- 定义好优先级:有 to-do list 并不是什么高深的事。更重要的是,你要知道什么事是重要的,什么事是紧急的,什么事重要但不紧急,什么事又重要又紧急。
- 最短作业优先:先把可以快速做完的事做完,看到 to-do list 上划掉一个任务,看到任何的数据在减少,对于自己也好,对于老板也好。老板可以看到你的工作进度飞快,一方面有利于为后面复杂的工作争取更多的时间。
- 想清楚再做:对于一些没想清楚的事,或是自己不太有信心的事,还是先看看有没有已有的成熟解决方案或是找更牛的人来给你把把关,帮你出出主意,看看有没有更好、更简单的方式。
- 关注长期利益规划:要多关注长远可以节省多少时间,而不是当前会花费多少时间。长期成本会比短期成本大得多。所以宁可在短期延期,也不要透支未来。学会规划自己的行动计划,不是短期的,而是一个中长期的。按季度来规划,这个季度做什么,达到什么目标,一年往前走四步,而不是只考虑眼下。
用好自己的时间
- 将军赶路不追小兔:学会过滤掉与自己目标无关的事,不要让那些无关的事控制自己。知道哪些是更有效的路径,是花时间改变别人,还是花时间去寻找志同道合的人。不与不如自己的人争论,也不要尝试花时间去叫醒那些装睡的人,这些都是非常浪费时间的事。多花时间在有产出的事上,少花时间在说服别人的事上。
- 形成习惯:将文章中提到的方法和几个要点,写在某本书或者笔记本的扉页上,方便查看,时刻提醒自己。
- 形成正反馈:要让自己有正反馈,那就需要把时间花在有价值的地方,比如,解决自己和他人的痛点,这样你会收获别人的赞扬和鼓励。
- 反思和举一反三:可以尝试每周末花上点时间思考一下,本周做了哪些事儿?时间安排是否合理?还有哪些可以优化提高的地方?有点儿类似于我们常说的“复盘”。然后思考一下,下周的主要任务是什么?并根据优先级规划一下完成这些任务的顺序,也就是做一些下周的工作规划。
17 | 故障处理最佳实践:应对故障
目前没有遇到实际场景,略过。
18 | 故障处理最佳实践:故障改进
目前没有遇到实际场景,略过。
19 | 答疑解惑:我们应该能够识别的表象和本质
本质上来说,并不是只有找到了相应的工作我们才可以学好一项技术,而是,我们在通过解决实际问题,在和他人讨论,获得高手帮助的环境中,才能更快更有效率地学习和成长。
关于技术和价值
一项有价值的技术,并不在于这项技术是否有技术含量,而是在于:
- 能否低成本高效率地解决实际问题;
软件天生就是用来完成重复劳动的,天生就是用来做自动化的。
- 是不是众多产品的基础技术;
基础技术总是枯燥和有价值的。数学、算法、网络、存储等基础技术吃得越透,就越容易服务上层的各种衍生技术或产品。
- 是不是可以支持规模化的技术。
能规模化低成本高效率解决实际问题的技术及其基础技术,就算是很 low,也是很有价值的。
关于趋势和未来
这个世界的技术趋势和未来其实是被人控制的,所以对于我们这些在这个世界里排不上号的人来说,只能默默地跟随着这些大公司所引领的趋势和未来。对一些缺钱缺人的创业公司,唯一能够做的,也许只是两条路,一是用更为低的成本来提供和大公司相应的技术,另一条路是在细分垂直市场上做得比大公司更专更精。等着自己有一天长大后,也能加入第一梯队从而“引领”未来。
20 | Git协同工作流,你该怎么选?
Git一直在使用,略过。