eBPF:现代Linux自省功能,或内核不再是黑匣子





每个人都有自己喜欢的关于魔法的书。有人拥有托尔金(Tolkien),有人拥有Pratchett,有人像我一样拥有Max Fry。今天,我将向您介绍我最喜欢的IT魔术-BPF及其周围的现代基础架构。



BPF目前处于高峰。该技术正在突飞猛进地发展,渗透到最意想不到的地方,对普通用户来说越来越容易获得。在今天几乎所有受欢迎的会议上,您都可以听到有关此主题的报告,GopherCon Russia也不例外:我为您提供报告的文本版本



本文将不会有独特的发现。我将尝试向您展示BPF是什么,它可以做什么以及如何为您提供个人帮助。我们还将介绍与Go相关的功能。



阅读完我的文章后,我真的希望您的眼睛像第一次阅读《哈利波特》书的孩子的眼睛一样亮起来,以便您回家或工作并尝试一个新的“玩具”。



什么是eBPF?



那么,一个34岁的留着胡子的留着胡子的大眼睛的男人会告诉你什么魔术呢?



我们将于2020年与您同住。如果您打开Twitter,您会读到脾气暴躁的先生们的推文,他们声称该软件的编写质量如此糟糕,以至于把它扔掉再重新开始更容易。有些人甚至威胁要离开该行业,因为他们无法忍受它了:一切都在不断崩溃,不舒服,缓慢。







也许他们是对的:没有一千条评论,我们将不会发现。但是,我绝对同意的是,现代软件堆栈比以往任何时候都更加复杂。



BIOS,EFI,操作系统,驱动程序,模块,库,网络,数据库,缓存,K8等编排器,Docker等容器,最后是我们的带有运行时和垃圾收集器的软件。真正的专业人员可以回答在您的浏览器中键入ya.ru几天后会发生什么的问题。



很难理解系统中正在发生的事情,尤其是当目前出问题了并且您正在亏钱时。这个问题导致了旨在帮助您了解系统内部正在发生的事情的业务线的出现。大型公司的整个Sherlock部门都知道在哪里锤打和拧紧哪个螺母可以节省数百万美元。



在访谈中,我经常问人们,如果凌晨四点醒来,他们将如何解决问题。



一种方法是分析日志。但是问题在于,只有开发人员放入其系统中的那些才可用。它们不灵活。



第二种流行的方法是研究指标。三种最受欢迎​​的指标系统是用Go编写的。指标非常有用,但是它们并不能总是通过让您看到症状来帮助您了解原因。



越来越流行的第三种方法是所谓的可观察性:能够提出有关系统行为的任意复杂问题并获得答案的能力。由于问题可能非常复杂,答案可能需要各种各样的信息,并且在提出问题之前,我们不知道是哪一个。这意味着灵活性对于可观察性至关重要。



是否具有即时更改日志记录级别的能力?将调试器连接到正在运行的程序,并在其中执行某些操作而不会中断其工作?了解哪些请求进入系统,可视化慢速请求的来源,查看通过pprof花费了多少内存,并获得其随时间变化的图表?衡量一个函数的延迟以及延迟对参数的依赖性?所有这些方法我都会提到可观察性。这是一组实用程序,方法,知识和经验,它们共同为您提供机会,即使不是全部,也可以在工作系统中进行大量投资。现代瑞士IT刀。







但是,这怎么办呢?市场上有很多工具:简单,复杂,危险,缓慢。但是今天的文章的主题是BPF。



Linux内核是事件驱动的。内核以及整个系统中几乎发生的所有事情都可以表示为一组事件。中断是一个事件,通过网络接收数据包是一个事件,处理器向另一个进程的转移是一个事件,功能的启动是一个事件。

因此,BPF是Linux内核的子系统,可以编写一些小程序,这些小程序将由内核响应事件启动。这些程序可以阐明系统中正在发生的事情并对其进行控制。



这是一个很长的介绍。让我们更接近现实。



1994年出现了BPF的第一个版本,在为tcpdump实用程序编写用于查看或嗅探网络数据包的简单规则时,您可能会遇到其中的某些版本。 tcpdump可以将“过滤器”设置为不全部查看,而是仅查看您感兴趣的软件包。例如,“仅tcp协议和仅端口80”。对于每个通过的数据包,运行一个功能来决定是否保存该特定数据包。可能有很多软件包,这意味着我们的功能必须非常快。我们的tcpdump过滤器刚刚转换为BPF函数,下图显示了一个示例。





作为BPF程序,提供了一个简单的tcpdump过滤器



最初的BPF是一个非常简单的虚拟机,带有多个寄存器。但是,尽管如此,BPF仍大大加快了网络数据包的过滤速度。一次,这是向前迈出的一大步。 







2014年,Alexey Starovoitov扩展了BPF功能。他增加了寄存器的数量和程序的允许大小,添加了JIT编译,并创建了验证程序来检查程序的安全性。但是最令人印象深刻的是,不仅可以在处理数据包时启动新的BPF程序,而且还可以响应大量内核事件,并在内核和用户空间之间来回传递信息。



这些更改为BPF的新用例开辟了道路。通过BPF,以前通过编写复杂而危险的内核模块完成的某些事情现在相对容易实现。为什么这很酷?因为编写模块时出现任何错误通常会导致恐慌。不是为了蓬松的Go-shnoy恐慌,而是为了内核恐慌,此后-仅重新启动。



现在,普通的Linux用户具有超强的能力,可以在后台进行查看,以前只有硬核内核开发人员或其他任何人都可以使用。此选项相当于可以毫不费力地为iOS或Android编写程序的能力:在较旧的手机上,这是不可能的,或者难度更大。



Alexey的BPF的新版本称为eBPF(从“扩展”一词扩展)。但是现在它已经取代了BPF的所有旧版本,并且变得如此流行,以至于每个人都简单地将其称为BPF。



在哪里使用BPF?



那么,哪些事件或触发器可以附加到BPF程序上?人们是如何开始利用这种新发现的力量的呢?



当前有两组大型触发器。



第一组用于处理网络数据包和管理网络流量。这些是XDP,流量控制事件等。



这些事件需要:



  • , . Cloudflare Facebook BPF- DDoS-. ( BPF- ), . .

  • , , — , , . . Facebook, , , .

  • 建立智能平衡器。最突出的例子是Cilium项目,该项目最常在K8s集群中用作网状网络。Cilium管理流量:平衡,重定向和分析流量。所有这些都是借助于内核为响应与网络数据包或套接字相关的一个或另一个事件而启动的小型BPF程序完成的。



这是与具有影响行为能力的网络问题相关的第一组触发器。第二类与更一般的可观察性有关。该组中的程序通常不具有影响某些东西的能力,而只能“观察”。她对我更感兴趣。



该组包含触发器,例如:



  • perf events — , Linux- perf: , , minor/major- . . , , , - . , , , , .

  • tracepoints — ( ) , (, ). , — , , , , . - , tracepoints :
    • ;

    • , ;

    • API, , , , , API.



      , , , , , pprof .


  • USDT — , tracepoints, user space-. . : MySQL, , PHP, Python. enable-dtrace . , Go . -, , DTrace . , , Solaris: , , GC -, .



好吧,然后魔术的另一层次开始了:



  • ftrace触发器使我们能够在几乎任何内核函数的开头运行BPF程序。充满活力。这意味着内核将在执行您选择的任何内核函数之前调用您的BPF函数。或所有内核功能-无论如何。您可以附加到所有内核函数,并获得输出中所有调用的清晰可视化。

  • kprobes / uprobes与ftrace具有几乎相同的功能,只有在执行函数时,我们才能在内核和用户空间中将其捕捉到任何位置。在函数的中间,如果在变量上存在某种if,则需要绘制此变量的值的直方图?没问题

  • kretprobes/uretprobes — , user space. , , . , , PID fork.



我再说一遍,关于这一切的最值得注意的事情是,在这些触发器中的任何一个上调用我们的BPF程序,都可以很好地了解一下:读取函数参数,定时,读取变量,全局变量,进行堆栈跟踪,保存然后供以后将数据传输到用户空间进行处理,从用户空间获取数据以进行过滤或某些控制命令。美女!



我不了解您,但对我而言,新的基础架构就像是我一直在焦急等待了很长时间的玩具。



API或使用方法



好的,Marco,您说服我们考虑了BPF。但是如何处理呢?



让我们看一下BPF程序由什么组成以及如何与之交互。







首先,我们有一个BPF程序,如果通过验证,它将被加载到内核中。当将它附加到触发器上时,它将JIT编译成机器代码并以内核模式运行。



BPF程序具有与第二部分(用户空间程序)进行交互的能力。有两种方法可以做到这一点。我们可以写一个循环缓冲区,而用户空间部分可以读取它。我们还可以在称为BPF映射的键值存储中进行写入和读取,而用户空间部分可以分别执行相同的操作,因此,它们可以相互传递一些信息。



直线路径



使用BPF的最简单方法(在任何情况下都不应从头开始)是编写类似于C语言的BPF程序,然后使用Clang编译器将此代码编译为虚拟机代码。然后,我们直接使用BPF系统调用加载此代码,并使用BPF系统调用与我们的BPF程序进行交互。







第一个可用的简化方法是使用libbpf库,该库随内核源一起提供,使您不能直接使用BPF系统调用。实际上,它提供了方便的包装器来加载代码,并与所谓的映射一起使用,以将数据从内核传输到用户空间并返回。



密件抄送



显然,这种使用远非人类友好。幸运的是,在iovizor品牌下,BCC项目出现了,极大地简化了我们的生活。







实际上,它准备了整个组装环境,并为我们提供了编写单个BPF程序的机会,其中C部分将被自动组装并加载到内核中,而用户空间部分可以使用简单易懂的Python来完成。



bpftrace



但是BCC在很多方面看起来也很复杂。由于某些原因,人们尤其不喜欢用C编写零件。



来自iovizor的同一个人介绍了bpftrace工具,该工具使您可以使用一种简单的脚本语言(如AWK)编写BPF脚本(或者通常是单行)。







著名的性能和可观察性专家Brendan Gregg为使用BPF的可用方法准备了以下可视化视图:







纵向上,我们拥有该工具的简单性,而水平上则拥有其强大的功能。可以看出,BCC是一个非常强大的工具,但并非超级简单。bpftrace简单得多,但功能却不那么强大。



使用BPF的示例



但是,让我们看一下具体示例,了解一下我们已经具备的神奇能力。



BCC和bpftrace都包含一个Tools文件夹,其中包含大量现成的有趣且有用的脚本。它们还是本地堆栈溢出,您可以从中复制脚本的代码块。



例如,以下脚本显示了DNS查询的延迟:



 ╭─marko@marko-home ~ 
╰─$ sudo gethostlatency-bpfcc
TIME      PID    COMM                  LATms HOST
16:27:32  21417  DNS Res~ver #93        3.97 live.github.com
16:27:33  22055  cupsd                  7.28 NPI86DDEE.local
16:27:33  15580  DNS Res~ver #87        0.40 github.githubassets.com
16:27:33  15777  DNS Res~ver #89        0.54 github.githubassets.com
16:27:33  21417  DNS Res~ver #93        0.35 live.github.com
16:27:42  15580  DNS Res~ver #87        5.61 ac.duckduckgo.com
16:27:42  15777  DNS Res~ver #89        3.81 www.facebook.com
16:27:42  15777  DNS Res~ver #89        3.76 tech.badoo.com :-)
16:27:43  21417  DNS Res~ver #93        3.89 static.xx.fbcdn.net
16:27:43  15580  DNS Res~ver #87        3.76 scontent-frt3-2.xx.fbcdn.net
16:27:43  15777  DNS Res~ver #89        3.50 scontent-frx5-1.xx.fbcdn.net
16:27:43  21417  DNS Res~ver #93        4.98 scontent-frt3-1.xx.fbcdn.net
16:27:44  15580  DNS Res~ver #87        5.53 edge-chat.facebook.com
16:27:44  15777  DNS Res~ver #89        0.24 edge-chat.facebook.com
16:27:44  22099  cupsd                  7.28 NPI86DDEE.local
16:27:45  15580  DNS Res~ver #87        3.85 safebrowsing.googleapis.com
^C%


该实用程序实时显示DNS查询的执行时间,因此您可以捕获例如一些意外的异常值。



这是一个脚本,“监视”其他人在其终端上键入的内容:



 ╭─marko@marko-home ~ 
╰─$ sudo bashreadline-bpfcc         
TIME      PID    COMMAND
16:51:42  24309  uname -a
16:52:03  24309  rm -rf src/badoo


这种脚本可用于捕获不良邻居或审核公司服务器的安全性。



用于查看高级语言的流调用的脚本:



 ╭─marko@marko-home ~/tmp 
╰─$ sudo /usr/sbin/lib/uflow -l python 20590
Tracing method calls in python process 20590... Ctrl-C to quit.
CPU PID    TID    TIME(us) METHOD
5   20590  20590  0.173    -> helloworld.py.hello                  
5   20590  20590  0.173      -> helloworld.py.world                
5   20590  20590  0.173      <- helloworld.py.world                
5   20590  20590  0.173    <- helloworld.py.hello                  
5   20590  20590  1.174    -> helloworld.py.hello                  
5   20590  20590  1.174      -> helloworld.py.world                
5   20590  20590  1.174      <- helloworld.py.world                
5   20590  20590  1.174    <- helloworld.py.hello                  
5   20590  20590  2.175    -> helloworld.py.hello                  
5   20590  20590  2.176      -> helloworld.py.world                
5   20590  20590  2.176      <- helloworld.py.world                
5   20590  20590  2.176    <- helloworld.py.hello                  
6   20590  20590  3.176    -> helloworld.py.hello                  
6   20590  20590  3.176      -> helloworld.py.world                
6   20590  20590  3.176      <- helloworld.py.world                
6   20590  20590  3.176    <- helloworld.py.hello                  
6   20590  20590  4.177    -> helloworld.py.hello                  
6   20590  20590  4.177      -> helloworld.py.world                
6   20590  20590  4.177      <- helloworld.py.world                
6   20590  20590  4.177    <- helloworld.py.hello                  
^C%


此示例显示了Python程序的调用堆栈。



同一个人的布伦丹·格雷格(Brendan Gregg)拍了一张照片,其中他收集了所有带有箭头的脚本,这些脚本指示每个实用程序都允许“观察”的子系统。如您所见,几乎在任何情况下,我们都已经有大量现成的实用程序可用。





不要试图在这里看到一些东西。图片仅供参考



那Go呢? 



现在让我们谈谈Go。我们有两个主要问题:



  • 可以在Go中编写BPF程序吗?

  • 可以解析用Go编写的程序吗?



让我们去吧。



迄今为止,唯一可以编译成BPF机器可以理解的格式的编译器是Clang。另一个流行的编译器GCC尚没有BPF后端。唯一可以编译为BPF的编程语言是C的非常有限的版本。



但是,BPF程序还有第二部分,即用户空间。它可以用Go编写。



如前所述,BCC允许您使用Python(该工具的主要语言)编写此部分。同时,在主存储库中,BCC还支持Lua和C ++,而在第三方存储库中,它还支持Go







这样的程序看起来与Python程序完全相同。在开始的一行中,用C语言编写了一个BPF程序,然后我们告诉该程序在何处附加,并以某种方式与之交互,例如,我们从EPF映射中获取数据。



实际上,仅此而已。您可以在Github上更详细地查看示例

可能的主要缺点可能是C库libbcc或libbpf用于工作,并且使用这样的库构建Go程序根本看起来不像在公园里散步。



除了iovisor / gobpf之外,我还发现了另外三个当前项目,这些项目使您可以在Go中编写userland部分。





Dropbox版本不需要任何C库,但是您必须使用Clang自己构建BPF程序的内核部分,然后使用Go程序将其加载到内核中。



Cilium版本具有与Dropbox版本相同的功能。但是值得一提的是,仅仅是因为它是由Cilium项目的家伙完成的,这意味着它注定要成功。



我带来了第三个项目以确保图片的完整性。像前两个一样,它没有外部C依赖项,需要手动组装BPF C程序,但是似乎没有什么希望。



实际上,还有一个问题:为什么要在Go中编写BPF程序?毕竟,如果您查看BCC或bpftrace,则BPF程序通常需要少于500行代码。用bpftrace语言编写脚本或发现一些Python难道不是很容易吗?我在这里看到两个原因。 



首先,您真的很喜欢Go,并且喜欢在上面做所有事情。此外,潜在的Go程序更容易在机器之间移植:静态链接,简单的二进制文件等等。但是,由于我们与特定的核心联系在一起,所以一切都不是那么明显。我将在这里停止,否则我的文章将再延伸50页。



第二种选择:您不是在编写简单的脚本,而是在内部也使用BPF的大型系统。我什至在Go中一个这样的系统的例子







Scope项目看起来像一个单一的二进制文件,当在K8s或其他云的基础结构中启动时,它分析周围发生的一切,并显示什么容器,服务是什么,它们如何交互等。其中大部分是使用BPF完成的。一个有趣的项目。



分析Go程序



如果您还记得,我们还有一个问题:我们可以分析使用BPF用Go编写的程序吗?首先想到的-当然!用什么语言编写程序有什么区别?毕竟,这只是一个编译后的代码,它像所有其他程序一样,在处理器上计算内容,像不占用内存一样吞噬内存,通过内核与硬件交互,以及通过系统调用与内核交互。原则上,这是正确的,但是具有不同难度级别的功能。



传递参数



功能之一是Go不使用大多数其他语言所使用的ABI。碰巧的是,开国元勋们决定采用计划9系统的ABI ,他们对此非常了解。



ABI就像一个API,一个互操作性协议,仅在位,字节和机器代码级别。



我们感兴趣的主要ABI元素是如何将其参数传递给函数以及如何将响应从函数传递回。标准的x86-64 ABI使用处理器寄存器来传递参数和响应,而Plan 9 ABI为此使用堆栈。



Rob Pike和他的团队不打算制定另一个标准:他们已经为Plan 9系统准备了一个几乎现成的C编译器,只需两个就可以了,他们很快将其转换为Go的编译器。实际的工程方法。



但是,实际上,这不是一个非常关键的问题。首先,我们可能很快就会在Go中看到通过寄存器传递参数的情况,其次,从BPF从堆栈中获取参数并不困难:别名sargX已经被添加到bpftrace中,并且同样会在BCC中出现,很可能在不久的将来...



更新:从我撰写报告的那一刻起,甚至出现了有关向ABI中的注册簿使用过渡的详细官方建议



唯一线程标识符



第二个功能与Go的最爱功能goroutines有关。衡量功能延迟的一种方法是节省调用功能所需的时间,退出功能所需的时间并计算差异。并使用包含函数名称和TID(线程号)的键保存开始时间。需要线程号,因为同一功能可以由不同程序或同一程序的不同线程同时调用。



但是在Go中,goroutines在系统线程之间行走:现在,goroutine在一个线程上执行,而稍后又在另一个线程上执行。在Go的情况下,我们不会将TID放入密钥中,而是将GID(即goroutine的ID)放入密钥中,但是我们无法获得它。从技术上讲,此ID存在。您甚至可以用肮脏的技巧将其撤出,因为它在堆栈中的某个位置,但是关键的Go开发组的建议严格禁止这样做。他们认为我们永远不需要这些信息。以及Goroutine的本地存储,但我离题。



扩展堆栈



第三个问题是最严重的。如此严重,以至于即使我们以某种方式解决了第二个问题,它也无法以任何方式帮助我们衡量Go函数的延迟。



可能大多数读者都很好地了解了什么是堆栈。与堆栈不同,可以在同一堆栈中为变量分配内存,而不用考虑释放它们。



如果我们谈论C,那么那里的堆栈大小是固定的。如果我们超出此固定大小,则会发生著名的堆栈溢出



在Go中,堆栈是动态的。在较旧的版本中,它是串联的内存块。现在它是一个连续的动态大小的块。这意味着如果所选的片段对我们来说不够用,我们将扩展当前片段。如果我们无法扩展,则选择另一个更大的数据,然后将所有数据从旧位置移动到新位置。这是一个令人着迷的迷人故事,涉及安全保证,cgo,垃圾收集器,但这是另一篇文章的主题。



重要的是要知道,为了使Go移动堆栈,它需要遍历程序的调用堆栈,堆栈中的所有指针。



这就是主要问题所在:用于附加BPF函数的输尿管探头在函数执行结束时动态更改堆栈,以内联对其处理程序的调用,即所谓的蹦床。而且在大多数情况下,Go堆栈中的这种意外更改会导致程序崩溃。糟糕!



但是,这个故事不是唯一的。 C ++“堆栈”展开器在异常处理时也会崩溃一次。



没有解决此问题的方法。在这种情况下,当事双方照常交流彼此有罪的绝对合理论点。



但是,如果您确实需要使用输尿管探头,则可以避免该问题。怎么样?不要放输尿管探针。我们可以在退出该功能的所有地方摆上床罩。可能有一个这样的地方,也许有50个。



在这里,Go的独特性发挥了作用。



通常,这种技巧是行不通的。一个足够聪明的编译器可以执行所谓的尾部调用优化,当我们不是从一个函数返回并沿着调用堆栈返回时,我们只是跳到下一个函数的开头。这种优化对于像Haskell这样的功能语言至关重要没有它,他们就不可能没有堆栈溢出就采取措施。但是通过这种优化,我们根本无法找到从函数返回的所有位置。



特殊之处在于Go编译器1.14版尚无法进行尾部调用优化。这意味着附加到函数的所有显式出口的技巧很有效,尽管很繁琐。



示例



不要认为BPF对Go没用。事实并非如此:我们可以做所有不会影响上述细微差别的事情。我们会的。 

让我们看一些例子。



让我们以一个简单的程序进行准备。基本上,它是一个侦听端口8080并具有HTTP请求处理程序的Web服务器。处理程序将从URL获取name参数,Go参数,并对“站点”进行某种检查,然后将所有三个变量(名称,年份和检查状态)发送到prepareAnswer()函数,该函数将响应准备为字符串。







站点验证是一个HTTP请求,它使用管道和goroutine检查会议站点是否已启动并正在运行。准备响应的功能只是将其全部转换为可读的字符串。



我们将通过一个简单的curl请求触发程序:







作为第一个示例,我们将使用bpftrace来打印程序的所有函数调用。我们在这里附加所有主要功能。在Go中,所有函数都有一个符号,看起来像软件包名称-点功能名称。我们的程序包是main,函数的运行时将是运行时。







当我卷曲时,将启动处理程序,站点验证功能和goroutine子功能,然后启动响应准备功能。类!



接下来,我不仅要显示正在执行的功能,还要显示它们的参数。让我们使用prepareAnswer()函数。她有三个论点。让我们尝试打印两个int。

我们采用bpftrace,只是现在不是单行代码,而是脚本。我们附加到函数上,并为我提到的堆栈参数使用别名。



在输出中,我们看到在2020年通过的状态为200,一次通过2021,







但是该函数具有三个参数。第一个是字符串。那么他呢?



让我们仅打印从0到4的所有堆栈参数。我们看到了什么?一些大数字,一些小数字,以及我们以前的2021和200。一开始这些奇怪的数字是什么?







在这里了解Go设备非常有用。如果在C语言中,字符串只是一个以空值终止的字符数组,那么在Go语言中,字符串实际上是一个结构,该结构由指向字符数组的指针(顺便说是非以空值终止)组成。







但是Go编译器在将字符串作为参数传递时会扩展此结构并将其作为两个参数传递。结果发现,第一个奇怪的数字只是指向我们数组的指针,第二个是长度。



而事实:字符串的预计长度为22。



因此,我们修正我们的脚本一点通过指针寄存器堆栈和正确的偏移量来获得这两个值,并且使用内置的功能STR(),我们将其输出为一个字符串。一切正常:







让我们看一下运行时。例如,我想知道我们的程序启动了哪些goroutine。我知道goroutine是由newproc()和newproc1()函数触发的。让我们连接到他们。 newproc1()函数的第一个参数是指向funcval结构的指针,该结构只有一个字段-函数指针:







在这种情况下,我们将利用这个机会直接在脚本中定义结构。这比玩偏移量集要容易一些。在这里,我们介绍了在调用处理程序时启动的所有goroutine。如果之后得到偏移量的符号名称,则在其中,我们将看到我们的checkSite函数。万岁!







这些示例说明了BPF,BCC和bpftrace功能的下降。有了适当的内部知识和经验,您可以从正在运行的程序中获取几乎所有信息,而无需停止或更改它。



结论



这就是我想告诉你的一切。希望我能启发您。



BPF是Linux中最流行,最有前途的趋势之一。而且我相信,在未来几年中,我们不仅会在技术本身,而且会在工具及其发行方面看到更多有趣的事情。



在为时已晚之前,并不是每个人都了解BPF,与其一起玩,成为魔术师,解决问题并帮助您的同事。他们说魔术只会起作用一次。



至于Go,与往常一样,我们发现它非常独特。我们总是有一些细微差别:要么编译器不同,要么ABI,我们需要某种GOPATH,该名称不能为Google。但是我们已经成为不容忽视的力量,我相信生活只会变得更好。



All Articles