标签 Go1.5 下的文章

Go项目目录该怎么组织?官方终于出指南了!

本文永久链接 – https://tonybai.com/2023/10/05/the-official-guide-of-organizing-go-project

长久以来,在Go语言进阶的学习和实践之路上,Go项目目录究竟如何布局一直是困扰大家的一个问题,这是因为Go官方针对这个问题迟迟没有给出说法,更没有提供标准供大家参考。仅有Go语言项目技术负责人Russ Cox在一个开源项目的issue中给出了他关于Go项目结构的最小标准布局的想法

熟悉我的博客/公众号的读者可能会知道,关于Go项目目录布局,我在以往文章中曾写过多次。在我的纸版书《Go语言精进之路》、极客时间的专栏Go语言第一课以及Go高级工程师训练营中,对Go项目目录组织与布局方式也都有过全面系统地说明。

我虽然很努力为大家答疑,提供的建议也很具参考价值,但这仅是我的个人观点,权威性有限,大家依然期待Go官方的说法。

近期Go官方文档集合中新增了一篇名为“Organizing a Go module”的文档,细读之后,我发现这不就是大家期待已久的Go项目目录布局的官方指南吗!

在这篇文章中,我们就来看看这份官方指南,看看官方推荐的Go项目目录布局是什么样子的。

1. Go项目的类型

我们知道Go项目(project)一般有两类:library和executable。library是以构建库为目的的Go项目,而executable则是以构建二进制可执行文件为目的的Go项目。

“Organizing a Go module”这篇文档也是按照Go项目类型为Gopher提供项目布局建议的。这篇文档将library类的项目叫作package类,executable类的项目叫作command。下面的示意图展示了“Organizing a Go module”这篇文档的说明顺序:

从图中看到,“Organizing a Go module”这篇文档总共给出7种项目的布局建议。接下来,我们就来逐一看一下。

2. 官方版Go项目目录布局指南

2.1 basic package

我们先从package类开始。最简单的package类的Go项目是basic package,下面就是一个basic package类的项目目录布局的示例:

project-root-directory/
├── go.mod
├── modname.go
└── modname_test.go

或

project-root-directory/
├── go.mod
├── modname.go
├── modname_test.go
├── auth.go
├── auth_test.go
├── hash.go
└── hash_test.go

我们看到basic package类项目非常简单,repo下面只有一个导出package,这个package包含一个或多个包源文件。以repo托管在github上为例,如果这个repo的url为github.com/someuser/modname,那么该repo下的module root和导出package的导入路径通常与repo url一致,都为github.com/someuser/modname。

你的代码要依赖该module,直接通过下面import语句便可以将该module导入:

import "github.com/someuser/modname"

注:本文的Go项目目录布局示例均来自或改自“Organizing a Go module”那篇文档。

2.2 basic command

和basic package一样,basic command类项目是以构建可执行二进制程序为目的的Go项目中最简单的一类。下面是basic command类项目的一个示例:

project-root-directory/
├── go.mod
└── main.go

或

project-root-directory/
├── go.mod
├── main.go
├── auth.go
├── auth_test.go
├── hash.go
└── hash_test.go

从示例我们可以看到,basic command类项目的repo下面只可构建出一个可执行文件,main函数放在main.go中,其他源文件也在repo根目录下,并同样放在main包中。

还是以repo托管在github上为例,如果这个repo的url为github.com/someuser/modname,那么我们可以通过下面命令安装这个command的可执行程序:

$go install github.com/someuser/modname@latest

2.3 package with supporting packages

稍复杂或规模稍大的一些package类项目,会将很多功能分拆到supporting packages中,并且通常项目作者是不希望导出这些supporting packages的,这样这些supporting packages便可以不作为暴露的API的一部分,后续重构和优化起来十分方便,对package的用户也是无感的。这样Go官方建议将这些supporting packages放入internal目录

注:internal目录是Go 1.4版本引入的机制,简单来说放在internal中的包是local的,不能导出到module之外,但module下的某些内部代码可以导入internal下的包。如今一般都会将internal放在项目的根目录下,所以项目下的所有代码都可以导入internal下的包。

下面是一个带有supporting packages的package类项目的目录布局示例:

project-root-directory/
├── go.mod
├── modname.go
├── modname_test.go
└── internal/
    ├── auth/
    │   ├── auth.go
    │   └── auth_test.go
    └── hash/
        ├── hash.go
        └── hash_test.go

modname.go或modname_test.go可以通过下面导入语句使用internal下面的包:

import "github.com/someuser/modname/internal/auth"

2.4 command with supporting packages

有了package with supporting packages的说明后,再来看command with supporting packages就更简单了,下面是一个示例:

project-root-directory/
├── go.mod
├── main.go
└── internal/
    ├── auth/
    │   ├── auth.go
    │   └── auth_test.go
    └── hash/
        ├── hash.go
        └── hash_test.go

和package with supporting packages不同的是,main.go使用的包名为main,这样Go编译器才能将其构建为command。

2.5 multiple packages

作为一个库项目,作者可能要暴露不止一个package,可能是多个packages。这不会给Go项目目录布局带来过多复杂性,我们只需多建立几个导出package的目录就ok了。下面是一个multiple packages的示例:

project-root-directory/
├── go.mod
├── modname.go
├── modname_test.go
├── auth/
│   ├── auth.go
│   ├── auth_test.go
│   └── token/
│       ├── token.go
│       └── token_test.go
├── hash/
│   ├── hash.go
│   └── hash_test.go
└── internal/
    └── trace/
        ├── trace.go
        └── trace_test.go

我们看到这个示例在repo(以托管在github.com/user/modname下为例)顶层放置了多个导出包:

github.com/user/modname
github.com/user/modname/auth
github.com/user/modname/hash

并且顶层的auth目录下还有一个二级的导出包token,其导入路径为:

github.com/user/modname/auth/token

所有这些导出包的supporting packages还是按惯例放在了internal目录下,比如:github.com/user/modname/internal/trace,这些包是local的,不能被该module之外的代码所依赖。

2.6 multiple commands

有multiple packages类型的项目,就会有multiple commands类的项目,下面是一个这类项目的示例:

project-root-directory/
├── go.mod
├── prog1/
│   └── main.go
├── prog2/
│   └── main.go
└── internal/
    └── trace/
        ├── trace.go
        └── trace_test.go

这个示例将每个command放置在一个单独的目录下(比如prog1、prog2等),supporting packages和之前的建议一样,统一放到internal下面。这样我们可以通过下面步骤来编译command:

$go build github.com/someuser/modname/prog1
$go build github.com/someuser/modname/prog2

command的用户通过下面步骤可以安装这些命令:

$go install github.com/someuser/modname/prog1@latest
$go install github.com/someuser/modname/prog2@latest

2.7 multiple packages and commands

最后我们来看看最复杂的一种项目类型:multiple packages and commands,即在同一个项目下面,既有多个可导出的packages,又有多个commands。下面是一个此类复杂项目的示例:

project-root-directory/
├── go.mod
├── modname.go
├── modname_test.go
├── auth/
│   ├── auth.go
│   ├── auth_test.go
│   └── token/
│       ├── token.go
│       └── token_test.go
├── hash/
│   ├── hash.go
│   └── hash_test.go
├── internal/
│       └── trace/
│           ├── trace.go
│           └── trace_test.go
└── cmd/
    ├── prog1/
    │   └── main.go
    └── prog2/
        └── main.go

我们看到:为了区分导出package和command,这个示例增加了一个专门用来存放command的cmd目录,prog1和prog2两个command都放在这个目录下。这也是Go语言的一个惯例。

这样,这个示例项目既导出了下面的包:

github.com/user/modname
github.com/user/modname/auth
github.com/user/modname/hash

又包含了两个可安装使用的command,用户按下面步骤安装即可:

$go install github.com/someuser/modname/cmd/prog1@latest
$go install github.com/someuser/modname/cmd/prog2@latest

3. 小结

经过对“Organizing a Go module”的文档这篇Go官方项目目录布局指南的学习,我发现指南中的建议与我个人在以往文章、书和专栏中对Go项目目录布局的建议非常相近,几乎一致,唯独不同的是在pkg目录的使用上。

在multiple packages类型项目中,如果要导出的package非常多,那么项目顶层目下会有大量的目录,这让项目顶层目录显得很“臃肿”,我个人建议将这些导出包统一放置到project-root-directory/pkg下面,这样项目顶层目录就会显得很简洁。

注:无论是“Organizing a Go module”这篇文档中的官方建议,还是我个人对Go项目目录布局的建议,针对的都是Go项目的基础布局。而像很多Gopher经常问的采用DDD、clean architecture或Hexagonal Architecture(六边形架构)设计的项目的目录布局是一种业务层面的布局,是在基础布局之上进行再设计的,不在本篇的说明范围之内。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go GC:了解便利背后的开销

本文永久链接 – https://tonybai.com/2023/06/13/understand-go-gc-overhead-behind-the-convenience

注:本文部分摘录自GopherChina 2023前的《Go高级工程师训练营》课程。

1. 简介

当今,移动互联网和人工智能的快(越)速(来)发(越)展(卷),对编程语言的高效性和便利性提出了更高的要求。Go作为一门高效、简洁、易于学习的编程语言,受到了越来越多开发者的青睐。

Go语言的垃圾回收机制(Garbage Collection,简称 GC)是其重要的运行机制之一,它可以帮助开发人员避免手动管理内存的复杂性和错误,为开发者带来开发上的便利,使开发者可以更专注于业务逻辑的实现。然而,GC的便利性背后也带来了一定的系统开销,作为成熟的Go开发者,我们需要了解GC带来的开销和优化方法,以帮助我们更好的了解和使用Go语言。

了解Go GC的原理是了解GC开销的前提条件,我们首先来简要看看Go GC的原理。

2. Go GC的简明原理

Go语言的垃圾回收器采用了并发三色标记清除算法(Concurrent Tri-Color Mark-And-Sweep),尽可能减少STW(stop the world)时间,以降低吞吐为代价换取低延迟,实现了高效的垃圾回收。

标记清除算法的基本原理是,垃圾回收器将所有的存活对象标记为“活”的,未被标记的对象则被认为是垃圾。经典的标记清除算法通常分为两个阶段:

  • 标记阶段:垃圾回收器从根对象开始,遍历所有可达对象,并将它们标记为“活”的。
  • 清除阶段:垃圾回收器从堆的起始地址开始遍历,将未被标记的对象清除,回收内存。

Go语言的垃圾回收器采用了三色标记法(Tri-Color Marking),将堆上的内存对象分为三种颜色:

  • 白色:未被标记为“活”的对象,是潜在的垃圾,后续可能会被GC回收。
  • 灰色:待扫描的对象,当扫描某个灰色对象时,GC会将其标记为黑色,然后将该对象指向的所有对象都标记为灰色,待后续标记。
  • 黑色:被标记为“活”的对象,在这轮GC中不会被回收。

垃圾回收器开始工作时不存在黑色对象,垃圾回收器会将根对象标记为灰色,并从根对象(通常是栈对象和全局对象)开始遍历。垃圾回收器会将灰色对象标记为黑色,并将该对象指向的对象标记为灰色。垃圾回收器重复这个过程,直到所有可达对象都被标记为黑色。最后,垃圾回收器清除所有未被标记为黑色的对象,即清除所有白色对象。

前面提到过,Go语言的GC采用了并发标记的技术,以减少GC对系统性能的影响。并发标记指的是在GC运行时程序仍然可以继续运行,而不必停止程序的执行。为了避免程序修改对象时对标记的影响,GC会利用混合写屏障技术,在对象被修改时进行特殊标记(若程序修改黑色对象(已被扫描完毕,不会再扫描),使之指向白色对象时,写屏障技术会将白色对象标记为灰色,避免白色对象被释放导致黑色对象出现悬挂指针的情况)。写屏障技术可以有效避免并发标记阶段的错误标记,但也会带来一定的性能开销

3. GC的开销

从上面的Go GC原理来看,GC在带来便利的同时,开销是不可避免的。

3.1 GC开销的主要来源

GC开销的主要来源包括以下几个:

  • STW时间

Go诞生初期,GC的实现不是很成熟,STW时间很长,这让很对想使用Go在生产上作为一番的开发人员打了“退堂鼓”。Go 1.5版本自举后,GC的STW时间大幅下降,又经过几个版本的打磨后,STW时间已经被Go降低到很短了,通常情况下都在1毫秒以内,甚至可以到几十微秒,STW时间的大幅缩短让Go真正走进了生产环境。

不过再短的STW对于程序执行来说也是开销,因为STW期间,所有属于业务逻辑的代码都无法向前推进(make progress)。

那么一个GC周期究竟会做几次STW呢?这里借用“Go语言原本”中的一个表格:

这个表格描述了Go垃圾回收器主要包含的五个阶段,我们看到虽然采用了并发三色标记和清除,但在一次GC周期内,还是要有2次STW,一次是结束标记,关闭写屏障,另一次是为下一个周期的并发标记做准备,开启写屏障。

STW时间依然是GC开销的主要来源之一。减少STW时间对于优化GC的性能依然至关重要,尤其是任意场景下都要保证尽可能短暂的STW,但这是Go core团队的任务。

  • 标记与清除阶段的负荷

在标记与清除阶段,GC需要遍历堆内存中的所有对象,并进行标记和清除,这也是十分消耗cpu的工作。

  • 标记辅助

GC的并发标记并非只是由特定(dedicated) goroutine去完成的,为了保证GC标记清扫的速度不低于业务goroutine分配内存的速度,保证程序不因消耗内存过快过大而被OS OOM(Out Of Memory) Killed,GC引入标记辅助技术,即让每个业务goroutine都有机会参与到GC标记工作中来!并且,这种标记辅助采用的是一种补偿机制,即该业务goroutine分配的内存越多,它要辅助标记的内存就越多。一旦某个业务goroutine被“拉壮丁”执行标记辅助工作,那么该goroutine的业务执行就会暂停,业务逻辑也就无法向前推进。

  • 堆内存的释放

当Go GC回收了堆内存之后,如果堆的大小变得比之前小了,那么垃圾回收器会向操作系统归还多余的内存空间。在Linux等操作系统中,操作系统会将这些内存页标记为“未使用”,但是这些内存页并不会立即返回给操作系统,而是留给程序使用,以便程序将来再次申请内存时可以直接使用已经分配的内存页,从而减少内存分配的时间和开销。当程序没有使用这些内存页一段时间后,操作系统会将这些内存页回收,并将它们标记为“可用”,并在需要时重新分配给程序。这个过程是由操作系统的虚拟内存管理机制来完成的,具体的开销取决于操作系统的实现和硬件的性能等因素。

3.2 度量GC的开销

由于标记辅助技术的存在,单纯地从每个GC cycle的执行时间以及GC间隔时间来度量GC开销似乎就不那么准确了,更为直观的反映GC开销的是GC消耗cpu的占比

不过目前上没有特别好的工具可以特别直观且直接告诉你当前Go程序执行时GC CPU占用率。我们可以通过pprof工具或类似Pyroscope这样的持续profiling的图形化工具来间接查看GC的cpu占用。

比如:通过Pyroscope提供的火焰图,查看runtime.gcBgMarkWorker(runtime后台专用的用于GC标记阶段的goroutine执行的函数)和runtime.gcAssistAlloc(标记辅助时调用的函数)的cpu消耗时间。

更为完整的Go runtime metrics指标,可以查看metrics包的文档

注:GODEBUG=gctrace=1可以输出关于每个GC周期的详细信息,关于详细信息中各个字段的解读可以参见这里。更高级的选手还可以使用Go execution tracer工具来剖析GC的开销。

GC的CPU开销占比通常在25%以下,一旦超过这个负荷比例,就要考虑做调优了,Go保证GC cpu占用不会超过50%

4. 优化GC的开销

优化GC的开销是提高系统性能和响应速度的重要手段。

前面我们分析了Go GC开销的主要来源。下面就针对每种来源说说优化开销的可能性与手段。

4.1 缩短STW时间

我们知道一旦GC STW后,所有业务逻辑都将暂停,这期间的CPU由GC 100%占用,降低STW时间是降低gc cpu占比的好方法。不过STW的算法是Go核心团队把控的,降低每个GC周期的STW时间也是Go核心团队的不二职责。从用户层面是很难影响到单次STW时间的。

不过,我们可以通过减少GC次数来间接减少STW次数,从而降低GC CPU占比。当然减少GC次数对后面的所有优化手段都有效,这是一个总开关。

那么如何减少GC次数呢?我们先来了解GC的触发时机。Go GC触发时机大体分为三种:

  • 手动触发:调用runtime.GC()
  • 常规触发:Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
  • sysmon后台周期性强制触发GC

我们看到,这三种触发时机我们能干预的只有常规触发,而常规触发的公式中,可以调整的只有GOGC这个参数(等价于debug.SetGCPercent())。GOGC默认值为100,也就是说当新分配heap内存的数量是上一周期的活跃heap内存的一倍的时候,触发GC:

如果我们将GOGC改为200,那么GC的触发间隔将增加,频度会下降,CPU开销会降低(6.4%->3.8%),如下图:

不过这是以整个程序的内存开销增大为代价的(40MB -> 60MB),并且对一般开发者而言,GOGC的值改起来确有风险,稍有不慎可能就会触发OMM killed。之前uber曾发表一篇文章,讲述了uber是如何通过在线自动调整GOGC参数来大幅降低CPU资源开销的,可以一看。

当然除了GOGC这一个唯一可调参数外,Go社区在降低GC频率方面也有自己的小妙招,比如之前经常使用的ballast(压舱石)技术。其原理就是在程序初始化时先分配一块大内存:

func main() {

    // Create a large heap allocation of 10 GiB
    ballast := make([]byte, 10<<30)

    // Application execution continues
    // ...
    runtime.KeepAlive(ballast) // make sure the ballast won't be collected
}

这块内存仅体现在VSZ中,即该程序进程的虚拟内存中,但并不占用程序进程的常驻内存(RSS)中。但一旦分配,Go GC就会将其算作是一个“活”堆内存对象,在计算下一次GC时就会将其作为上述公式中的live heap考量。如果ballast为10GB,那么GC就会在程序每新分配10GB内存时才会被触发。

注:RSS是这个进程目前在主内存(RAM)中拥有多少内存。VSZ是该进程总共有多少虚拟内存。

Go 1.19版本引入了Soft memory limit,这个方案在runtime/debug包中添加了一个名为SetMemoryLimit的函数以及GOMEMLIMIT环境变量,通过他们任意一个都可以设定Go应用的Memory limit。

一旦设定了Memory limit,当Go堆大小达到“Memory limit减去非堆内存后的值”时,一轮GC会被触发。即便你手动关闭了GC(GOGC=off),GC亦会被触发。 不过soft memory limit不保证不会出现oom-killed。并且如果一个Go应用的live heap object超过了soft memory limit但还尚未被kill,那么此时GC可能会被频繁触发,将大量消耗cpu资源:

但为了保证在这种情况下业务依然能继续进行,soft memory limit方案保证GC最多只会使用50%的CPU算力,以保证业务处理依然能够得到cpu资源。

那么多大的值是合理的soft memory limit值呢?在Go服务独占容器资源时,一个好的经验法则是留下额外的5-10%的空间。uber在其博客中设定的limit为资源上限的70%,也是一个不错的经验值。

Memory Limit被看作是Go官方的ballast替代方案,但还是不有所不同的。Memory limit只是规定了一个上限,如果未到memory limit,Go的常规GC还是会照例执行的。GOGC=off+ soft Memory limit下的行为特征与ballast更类似,不过将GC关掉的风险还是很大的,要三思而后行。

Go GC没有采用分代机制,每次都是FullGC,减少GC次数确是降低GC CPU开销的良方。不过除此之外,我们还有一个优化GC开销的方法,我们继续看。

4.2 减少堆内存的分配和释放

GC开销大的根源在于heap object多,Go的每轮GC都是FullGC,每轮都要将所有heap object标记(mark)一遍,即便大多数heap object都是长期alive的,因此,一个直观的降低GC开销的方法就是减少heap object的数量,即减少alloc

沿着这样的思路,我们可以很直接的想出如下两种手段:

  • 把小对象聚合到一个结构体中,然后做一次分配即可

这样不仅利于减少分配次数,还有利于减少堆内存碎片,提高堆内存的利用率。如果整个结构体中没有指针对象,那么结构体的分配与释放将更加高效,具体原因可参见我的《Go GC如何检测内存对象中是否包含指针》一文。

  • 重用

Go GC开销优化的一个典型手段就是内存空间重用,即建立一个池子,需要的时候从池中申请,用完后再放回池子里,供其他goroutine重用。这个过程不再有分配与释放。

Go中最典型的重用的例子就是sync.Pool的使用,不过sync.Pool并非完全不做释放操作,它是在一定程度上提高了重用的比例罢了。

5. 小结

Go GC的自动内存管理减少了内存泄漏和悬挂指针等问题。然而,GC给开发者带来便利的同时,开销也是不可避免的,它会对系统的性能和响应速度产生影响。Go开发者需要了解这些开销。

在本文中,我们介绍了GC的基本原理、GC的开销及其主要来源,并提供了优化GC开销的一些方法。

然而,要想有效地利用 GC,开发者需要了解其内部机制和算法,并根据实际情况进行调优。

除了通过GC参数降低GC频率外,在实际编码过程中,开发者还应该尽可能地减少对象的分配以降低Go每轮FullGC扫描对象的数量。

GC的优化是一项长期的工作。开发者应该不断地监控系统的性能和行为,并根据需要进行调整和优化,以确保系统的性能和响应速度始终保持在最佳状态。

6. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats