标签 OS 下的文章

slog实战:文件日志、轮转与kafka集成

本文永久链接 – https://tonybai.com/2023/09/04/slog-in-action-file-logging-rotation-and-kafka-integration

slog正式版来了:Go日志记录新选择!》一文发布后,收到了很多读者的反馈,意见集中在以下几点:

  • 基于slog如何将日志写入文件
  • slog是否支持log轮转(rotation),如果slog不支持,是否有好的log轮转插件推荐?
  • 如何与kafka集成
  • 日志输出有哪些最佳实践

这篇文章就是对上述问题进行补充说明的,供大家参考,希望能给大家带去帮助。

1. 输出日志到文件

之所以《slog正式版来了:Go日志记录新选择!》一文中使用的例子都以os.Stdout(标准输出)为log输出目的地,主要是因为基于云原生微服务架构模式下,应用都跑在容器中(k8s的pod中),基本都是将log输出到Stdout,而不会写入某个具体的本地文件。但如果应用是基于虚拟机或裸机部署,那么将日志写入文件仍然是第一选项。

其实,使用slog内置的TextHandler和JSONHandler可以非常方便的将结构化的日志写入文件,因为slog.NewXXXHandler函数的第一个参数是一个io.Writer,这样通过将一个文件的描述符传递给NewXXXHandler,即可创建一个向文件写入日志的Logger。我们看下面示例代码:

//slog-in-action/log2file/main.go

package main

import (
    "log/slog"
    "os"
)

func main() {
    f, err := os.Create("foo.log")
    if err != nil {
        panic(err)
    }
    defer f.Close()
    logger := slog.New(slog.NewJSONHandler(f, nil))
    slog.SetDefault(logger)
    slog.Info("greeting", "say", "hello")
}

在这个示例中,我们创建了目标日志文件foo.log,并将其描述符(*os.File)传给了NewJSONHandler函数,通过这种方式创建出来的Logger输出的日志内容将会被写入foo.log文件中:

$go run main.go
$cat foo.log
{"time":"2023-09-02T19:38:45.441782+08:00","level":"INFO","msg":"greeting","say":"hello"}

这种方式应该可以满足大多数gopher的需求了。

2. 日志文件的管理

一旦将日志写入文件,后续就要对日志文件进行管理,比如:日志文件的轮转、压缩、归档以及定期清理(腾出磁盘空间)等。

关于如何对日志文件管理的方案大致有这么几种。

第一种是借助外部工具,比如在主流的Linux发行版上都有一个logrotate工具程序,应用程序可以借助该工具对应用输出的日志进行rotate、压缩、归档和删除历史归档日志,这样可大幅简化应用的日志输出逻辑,应用仅需要将日志输出到一个具名文件中即可,其余都交给logrotate处理。关于如何使用logrotate,我在《写Go代码时遇到的那些问题[第1期]》中有详细说明,感兴趣的朋友可以移步阅读一下,这里就不赘述了。

第二种就是log包自身支持。大多数log包都没有将日志文件管理作为自己的功能feature,slog包也是如此,没有原生提供此功能。

第三种就是通过支持log包相关插件接口的一些扩展包来支持。lumberjack就是这样的一个插件包,它支持与很多知名的log包集成在一起实现对log文件的管理,比如logrus、zap等。我曾在《写Go代码时遇到的那些问题[第3期] 》《一文告诉你如何用好uber开源的zap日志库》两篇文章中分别讲解了logrus和zap与lumberjack集成在一起对日志文件进行管理的方法。如果你对lumberjack不是很熟悉,建议你在继续阅读下面内容之前,温习一下这两篇文章。

在这一篇文章中,我们用示例来简单说说如何将slog与lumberjack集成以实现对log文件的管理功能。看下面示例:

//slog-in-action/lumberjack/main.go

package main

import (
    "log/slog"

    "gopkg.in/natefinch/lumberjack.v2"
)

func main() {
    r := &lumberjack.Logger{
        Filename:   "./foo.log",
        LocalTime:  true,
        MaxSize:    1,
        MaxAge:     3,
        MaxBackups: 5,
        Compress:   true,
    }
    logger := slog.New(slog.NewJSONHandler(r, nil))
    slog.SetDefault(logger)

    for i := 0; i < 100000; i++ {
        slog.Info("greeting", "say", "hello")
    }
}

在这个示例中,我们看到:*lumberjack.Logger实现了io.Writer接口,因为只要将实例化后的*lumberjack.Logger以参数形式传入NewXXXHandler即可完成slog与lumberjack的集成。至于日志文件的管理行为则是通过lumberjack.Logger实例化过程的字段赋值来定制的。比如这里我们指定了目标日志文件名(Filename)为”./foo.log”,指定当文件达到1M字节时(MaxSize)进行rotate,对rotate后的文件进行压缩(Compress),最多保留5个归档文件(MaxBackups)以及归档文件最多保存3天(MaxAge)等。

运行上述示例程序后,我们将在当前目录想得到如下文件:

$go run main.go
$ls
foo-2023-09-02T08-24-20.854.log.gz  foo-2023-09-02T08-24-20.979.log.gz  foo-2023-09-02T08-24-21.098.log.gz  go.mod  main.go
foo-2023-09-02T08-24-20.918.log.gz  foo-2023-09-02T08-24-21.041.log.gz  foo.log                 go.sum

foo.log是当前正在写入的日志文件,而其他带有时间戳、以gz为后缀的文件则是归档文件。由于有了lumberjack对日志文件的管理,我们就不用再担心日志文件size过大、归档文件过多没有清理而导致的磁盘被占满的问题了。

注:lumberjack.Logger的各个属性字段的配置要根据你的应用实际输出日志的情况、本地磁盘可用空间来确定。

3. 与kafka集成

在我们团队的一个生产项目中,日志是不落盘而直接写入kafka的,关于这个事情,我在《Go社区主流Kafka客户端简要对比》一文中也曾提到过,并给出了基于zap和不同kafka客户端实现向kafka写入日志的方案。

slog与kafka集成的思路也是类似的,不同的是定制KafkaHandler的方法,基于slog,我们要让KafkaHandler实现slog.Handler接口。在《slog正式版来了:Go日志记录新选择!》一文中,我们给出了一个向channel写入结构化日志的示例,KakfaHandler完全可以借鉴其中的ChanHandler,也是通过字节切片来承接JSONHandler写出的日志,不同的是将写入Channel改为通过kafka client写入Kafka! 在这里我就不给出KakfaHandler的实现了,这个作业留给大家,记得实现KafkaHandler后,使用slog/slogtest对其正确性做一个测试!

注:注意在实现KakfaHandler时,考虑goroutine并发使用同一个基于KafkaHandler创建的slog.Logger的情况,也就是字节切片的并发访问和共享的问题。

4. 日志输出的实践建议

在《聊聊Go应用输出日志的工程实践》一文中,我聊了一些在日常使用log时遇到的问题、解决方法以及Go团队对log支持上的问题。log/slog的正式发布,一定程度上解决或改善了那篇文章中提到的部分问题。

此外,在读者关心的日志输出内容方面有哪些实践建议,我也总结了以下几点:

1). 选择合适的日志级别。常见的日志级别包括 DEBUG、INFO、WARNING和ERROR。在生产环境中,我们通常将日志级别设置为WARNING或ERROR,最低是info,不能再低了,避免打印过多日志以影响应用性能。

2). 日志级别要支持热更新。在系统出现异常时,如果要做在线调试,支持热更新的日志级别就特别重要,我们可以在一个调试时间窗口将日志级别下调至info或debug,这样可以抓取到一段时间的详细日志,以供调试和诊断参考。

3). 优先选结构化日志。相对于文本日志更适合人类阅读,结构化日志更适于机器解析、索引和查询。大多数正常情况下,我们是不会去看日志的,日志都会被汇集到集中日志中心存储、管理并索引(比如常见的ELK方案、近来的grafana的PLG方案(Promtail, Loki and Grafana)等),以便于后续做查询和展示。针对这样的情况,显然结构化日志更适合。

4). 无论使用结构化还是文本形式日志,日志格式都要清晰易读。每条日志至少要打印时间、日志级别、事件源、事件详情等信息,对于固定的字段,要用属性(attribute)来设置,以提高输出性能。

5). 考虑到排查和诊断业务问题,通常会为日志添加上下文信息。比如:在日志中增加关于当前用户、请求ID等上下文信息等。但不应该在日志中输出用户的隐私数据等敏感信息,要么移除,要么做打码处理。

6). 考虑到监控和告警的需要,有些时候我们会对错误日志进行监控,可能会在日志中放置一些具有监控意义的特征字段。

7). 对于日志写入文件的情况,就如本文前面提到的,要考虑日志文件的管理:设置合理的分割轮转日志文件策略以及日志文件的归档管理,避免日志文件的无限增长对磁盘带来的影响。

日志输出内容没有“固定标准”,需根据大家实际所处的业务环境以及相关要求确定。

5. 小结

本文是《slog正式版来了:Go日志记录新选择!》一文的“补充篇”,主要对将slog日志如何写入文件以及对文件的管理(轮转、归档、清理等方案)做了说明。对于将slog与外部系统(如kafka)进行集成的思路做了点拨,最后还给出了一些关于日志输出实践方面的参考意见,希望能帮助到大家!

本文涉及的示例代码可以在这里下载。


“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