标签 API 下的文章

Go语言中常见的几种反模式[译]

本文翻译自Saif Sadiq的文章《Common anti-patterns in Go》

众所周知,编码是一门艺术,就像每个拥有精湛艺术并为之感到骄傲的工匠一样,我们作为开发人员也为我们编写的代码感到自豪。为了获得最佳效果,艺术家不断寻找可提高其手艺的方法和工具。同样,作为开发人员,我们也在不断提高自己的技能,并对”如何写出好的代码”这个最重要的问题的答案保持好奇。

弗雷德里克·布鲁克斯(Frederick P. Brooks)在他的书《人月神话》中写道:

“程序员和诗人一样,工作时只是稍稍脱离了纯粹的思维定式。他在空气中建造他的城堡,通过发挥想象力进行创作。很少有一种创作媒介是如此灵活,如此容易打磨和重做,如此容易实现宏大的概念结构”。


图片来源:https://xkcd.com/844

这篇文章试图探索上面漫画中大问号的答案。编写良好代码的最简单方法是避免在我们编写的代码中包含反模式。

0. 什么是反模式

一个简单的反模式示例就是编写一个API,而无需考虑该API的使用者如何使用它,如下面的示例1所述。意识到反模式并有意识地避免在编程时使用它们,这无疑是朝着更具可读性和可维护性的代码库迈出的重要一步。在本文中,让我们看一下Go中一些常见的反模式。

当编写代码时没有未来的因素做出考虑时,就会出现反模式。反模式最初可能看起来是一个适当的问题解决方案,但是,实际上,随着代码库的扩大,这些反模式会变得模糊不清,并给我们的代码库添加“技术债务”。

反模式的一个简单例子是,在编写API时不考虑API的消费者如何使用它,就如下面例1那样。意识到反模式,并在编程时有意识地避免使用它们,肯定是迈向更可读和可维护的代码库的重要一步。在这篇文章中,我们来看看Go中常见的几种反模式。

1. 从导出函数(exported function)返回未导出类型(unexported type)的值

在Go中,要导出(export)任何一个字段(field)或变量(variable),我们都需要确保其名称是以大写字母开头。导出(export)它们的动机是使它们对其他包可见。例如,如果要使用math包中的Pi函数,我们将其定义为math.Pi。而使用math.pi将无法正常工作,并且会报错。

以小写字母开头的名称(结构字段,函数或变量)不会被导出,并且仅在定义它们的包内可见。

使用返回未导出类型值的导出函数或方法可能会令人沮丧,因为其他包中的该函数的调用者将不得不再次定义一个类型才能使用它。

// 反模式
type unexportedType string

func ExportedFunc() unexportedType {
    return unexportedType("some string")
} 

// 推荐
type ExportedType string
func ExportedFunc() ExportedType {
    return ExportedType("some string")
}

2. 空白标识符的不必要使用

在各种情况下,将值赋值给空白标识符是不需要,也没有必要的。如果在for循环中使用空白标识符,Go规范中提到:

如果最后一个迭代变量是空白标识符,则range子句等效于没有该标识符的同一子句。

// 反模式
for _ = range sequence {
    run()
}
x, _ := someMap[key]
_ = <-ch 

// 推荐
for range something {
    run()
} 

x := someMap[key]
<-ch

3. 使用循环/多次append连接两个切片

将多个切片附加到一个切片时,无需遍历切片并一个接一个地附加(append)每个元素。相反,使用一个append语句执行此操作会更好,更有效率。

例如,下面的代码段通过迭代遍历元素逐个附加元素来连串连接sliceOne和sliceTwo:

for _, v := range sliceTwo {
    sliceOne = append(sliceOne, v)
}

但是,由于我们知道append是一个变长参数函数,我们可以使用零个或多个参数来调用它。因此,可以仅使用一个append函数调用来以更简单的方式重写上面的示例,如下所示:

sliceOne = append(sliceOne, sliceTwo…)

4. make调用中的冗余参数

该make函数是一个特殊的内置函数,用于分配和初始化map、slice或chan类型的对象。为了使用make初始化切片,我们必须提供切片的类型、切片的长度以及切片的容量作为参数。在使用make初始化map的情况下,我们需要传递map的大小作为参数。

但是,make的这些参数已经具有默认值:

  • 对于channel,缓冲区容量默认为零(不带缓冲)。
  • 对于map,分配的大小默认为较小的起始大小。
  • 对于切片,如果省略容量,则容量参数的值默认为与长度相等。

所以,

ch = make(chan int, 0)
sl = make([]int, 1, 1)

可以改写为:

ch = make(chan int)
sl = make([]int, 1)

但是,出于调试或方便数学计算或平台特定代码的目的,将具名常量与channel一起使用不被视为反模式。

const c = 0
ch = make(chan int, c) // 不是反模式

5. 函数中无用的return

return在没有返回值的函数中作为最终语句不是一种好习惯。

// 没用的return,不推荐
func alwaysPrintFoofoo() {
    fmt.Println("foofoo")
    return
} 

// 推荐
func alwaysPrintFoo() {
    fmt.Println("foofoo")
}

但是,具名返回值的return不应与无用的return相混淆。下面的return语句实际上返回了一个值。

func printAndReturnFoofoo() (foofoo string) {
    foofoo := "foofoo"
    fmt.Println(foofoo)
    return
}

6. switch语句中无用的break语句

在Go中,switch语句不会自动fallthrough。在像C这样的编程语言中,如果前一个case语句块中缺少break语句,则执行将进入下一个case语句中。但是,人们发现,fallthrough的逻辑在switch-case中很少使用,并且经常会导致错误。因此,包括Go在内的许多现代编程语言都将switch-case的默认逻辑改为不fallthrough。

因此,在一个case case语句中,不需要将break语句作为最终语句。以下两个示例的行为相同。

反模式:

switch s {
case 1:
    fmt.Println("case one")
    break
case 2:
    fmt.Println("case two")
}

好的模式:

switch s {
case 1:
    fmt.Println("case one")
case 2:
    fmt.Println("case two")
}

但是,为了在Go中switch-case中实现fallthrough机制,我们可以使用fallthrough语句。例如,下面给出的代码段将打印23。

switch 2 {
case 1:
    fmt.Print("1")
    fallthrough
case 2:
    fmt.Print("2")
    fallthrough
case 3: fmt.Print("3")
}

7. 不使用辅助函数执行常见任务

对于一组特定的参数,某些函数具有一些特定表达方式,可以用来简化效率,并带来更好的理解/可读性。

例如,在Go中,要等待多个goroutine完成,可以使用sync.WaitGroup。通过将计数器的值-1直至0,以表示所有goroutine都已经执行完毕:

wg.Add(1) // ...some code
wg.Add(-1)

但使用sync包提供的辅助函数wg.Done()可以使代码更简单并容易理解。因为它本身会通知sync.WaitGroup所有goroutine即将完成,而无需我们手动将计数器减到0。

wg.Add(1)
// ...some code
wg.Done()

8. nil切片上的冗余检查

nil切片的长度为零。因此,在计算切片的长度之前,无需检查切片是否为nil切片。

例如,下面的nil检查是不必要的。

if x != nil && len(x) != 0 { // do something
}

上面的代码可以省略nil检查,如下所示:

if len(x) != 0 { // do something
}

9. 太复杂的函数字面量

可以删除仅调用单个函数且对函数内部的值没有做任何修改的函数字面量,因为它们是多余的。可以改为在外部函数直接调用被调用的内部函数。

例如:

fn := func(x int, y int) int { return add(x, y) }

可以简化为:

add(x, y)

译注:原文少了简化后的代码,这里根据译者的理解补充的。

10. 使用仅有一个case语句的select语句

select语句使goroutine等待多个通信操作。但是,如果只有一个case语句,实际上我们不需要使用select语句。在这种情况下,使用简单send或receive操作即可。如果我们打算在不阻塞地发送或接收操作的情况处理channel通信,则建议在select中添加一个default case以使该select语句变为非阻塞状态。

// 反模式
select {
    case x := <-ch: fmt.Println(x)
} 

// 推荐
x := <-ch
fmt.Println(x)

使用default:

select {
    case x := <-ch:
        fmt.Println(x)
    default:
        fmt.Println("default")
}

11. context.Context应该是函数的第一个参数

context.Context应该是第一个参数,一般命名为ctx.ctx应该是Go代码中很多函数的(非常)常用参数,由于在逻辑上把常用参数放在参数列表的第一个或最后一个比较好。为什么这么说呢?因为它的使用模式统一,可以帮助我们记住包含该参数。在Go中,由于变量可能只是参数列表中的最后一个,因此建议将context.Context作为第一个参数。各种项目,甚至Node.js等都有一些约定,比如错误先回调。因此,context.Context应该永远是函数的第一个参数,这是一个惯例。

// 反模式
func badPatternFunc(k favContextKey, ctx context.Context) {
    // do something
}

// 推荐
func goodPatternFunc(ctx context.Context, k favContextKey) {
    // do something
}

“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,>每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需>求!部落目前虽小,但持续力很强。在2021年上半年,部落将策划两个专题系列分享,并且是部落独享哦:

  • Go技术书籍的书摘和读书体会系列
  • Go与eBPF系列

欢迎大家加入!

Go技术专栏“改善Go语⾔编程质量的50个有效实践”正在慕课网火热热销中!本专栏主要满足>广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订
阅!

img{512x368}

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖>中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商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
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

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

以单件方式创建和获取数据库实例

img{512x368}

在屡次的Go用户调查中,使用Go语言进行Web服务/API开发都占据了Go语言用途调查结果的头部位置。下面是知名Go IDE goland的母公司JetBrains最新发布的Go当前状态报告(2021.2.3)中的截图:

img{512x368}

开发Web或API服务,难免会与数据库打交道。如今创建数据库实例并访库的技术已经是很成熟了,于是就有了下面这样的程序结构:

img{512x368}

上面这个图片中,Web服务中的每个要与数据库进行数据交互的包都是自己创建并使用数据库实例,这显然是一种糟糕的设计,它不仅让每个包都耦合外部的第三方数据包,每个包还担负起管理数据库连接的责任,并且在Web服务的整个项目中,还会存在多处获取数据库连接配置、打开关闭数据库等的重复代码。一旦数据库访问代码发生变化,这些包就都得修改一遍。

那么如何优化呢?一个很自然的想法:将创建数据库实例以及对数据库实例的获取封装到一个包中,其他包无需再关心数据库实例的创建与释放,直接获取和使用实例即可,如下面示意图:

img{512x368}

从这段描述来看,这显然是单件(singleton,亦翻译为单例)这个“创建型”模式的应用场景。在这里我们给出一个用Go实现的以单件方式创建和获取数据库实例的demo。

Go语言标准库提供了sync.Once类型,这让Go实现单件模式变得天然简单了。为了模拟上述场景,我们先来描述一下demo项目的结构:

database-singleton
├── Makefile
├── cmd
│   └── main
│       └── main.go
├── conf
│   └── database.conf
├── go.mod
├── go.sum
└── pkg
    ├── config
    │   └── config.go
    ├── db
    │   └── db.go
    ├── model
    │   └── employee.go
    ├── reader
    │   └── reader.go
    └── updater
        └── updater.go

在database-singleton这个repo中:

  • pkg/db就是我们将数据库实例的创建和获取封装到单件中的实现;
  • pkg/reader和pkg/updater则模拟了两个通过单件获取数据库实例并分别读取和更新数据库的包;
  • pkg/config是数据库连接配置的读取包。关于go程序的配置读取方案的一些方案,可以参考我的《写Go代码时遇到的那些问题[第1期]》一文。

我们从cmd/main/main.go中,可以看到整个程序的运行结构:

// github.com/bigwhite/experiments/blob/master/database-singleton/cmd/main/main.go
package main

import (
    "log"
    "os"
    "os/signal"
    "sync"
    "syscall"

    "github.com/bigwhite/testdboper/pkg/config"
    "github.com/bigwhite/testdboper/pkg/db"
    "github.com/bigwhite/testdboper/pkg/reader"
    "github.com/bigwhite/testdboper/pkg/updater"
)

func init() {
    err := config.Init()
    if err != nil {
        panic(err)
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    var quit = make(chan struct{})

    // do some init from db
    _ = db.DB()

    go func() {
        updater.Run(quit)
        wg.Done()
    }()
    go func() {
        reader.Run(quit)
        wg.Done()
    }()

    c := make(chan os.Signal, 1)
    signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)

    _ = <-c
    close(quit)
    log.Printf("recv exit signal...")
    wg.Wait()
    log.Printf("program exit ok")
}

简单解释一下上面main.go中的代码:

  • 在init函数中,我们读取了用于整个程序的配置信息,主要是数据库的连接信息(ip、port、user、password等);
  • 我们启动了两个独立的goroutine,分别运行reader和updater两个模拟数据库读写场景的包;
  • 我们使用quit channel通知两个goroutine退出,并使用sync.WaitGroup来等待两个goroutine的结束(关于goroutine的并发模式的详解,可以参考我的专栏文章《Go并发模型和常见并发模式》
  • 我们使用signal.Notify监听系统信号,并在收到系统信号后做出响应(关于signal包的使用,请参见我的专栏文章《小心被kill!不要忽略对系统信号的处理》)。

在main函数代码中,我们看到了如下调用:

    // do some init from db
    _ = db.DB()

这是在初始化的时候通过单件获取访问数据库的对象实例,但这个不是必须的,只有在初始化需要从数据库读取一些信息时才会用到。

接下来,我们就来看看创建数据库访问实例的单件是如何实现的:

// github.com/bigwhite/experiments/blob/master/database-singleton/pkg/db/db.go
package db

import (
    "fmt"
    "sync"
    "time"

    "github.com/bigwhite/testdboper/pkg/config"
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

var once sync.Once

type database struct {
    instance    *gorm.DB
    maxIdle     int
    maxOpen     int
    maxLifetime time.Duration
}

type Option func(db *database)

var db *database

func WithMaxIdle(maxIdle int) Option {
    return func(d *database) {
        d.maxIdle = maxIdle
    }
}
func WithMaxOpen(maxOpen int) Option {
    return func(d *database) {
        d.maxOpen = maxOpen
    }
}

func DB(opts ...Option) *gorm.DB {
    once.Do(func() {
        db = new(database)
        for _, f := range opts {
            f(db)
        }

        dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local",
            config.Config.Database.User,
            config.Config.Database.Password,
            config.Config.Database.IP,
            config.Config.Database.Port,
            config.Config.Database.DB)
        var err error
        db.instance, err = gorm.Open("mysql", dsn) // database: *gorm.DB
        if err != nil {
            panic(err)
        }

        sqlDB := db.instance.DB()
        if err != nil {
            panic(err)
        }

        if db.maxIdle != 0 {
            sqlDB.SetMaxIdleConns(db.maxIdle)
        }

        if db.maxLifetime != 0 {
            sqlDB.SetConnMaxLifetime(db.maxLifetime)
        }

        if db.maxOpen != 0 {
            sqlDB.SetMaxOpenConns(db.maxOpen)
        }

    })
    return db.instance
}

  • 首先,上述代码使用sync.Once对象辅助实现单件模式,传给once.Do方法的函数在整个程序生命周期中执行且只执行一次。我们就是在这个函数中创建的数据库访问实例;
  • 这里我们使用gorm库承担访问数据库的任务,因此所谓的实例,即gorm.DB类型的指针;
  • gorm.DB类型是并发安全的,我们无需考虑单件返回的实例的并发访问问题;
  • gorm.DB底层使用的是标准库database/sql维护的连接池,因此一旦gorm.DB实例建立成功,对连接的维护也全部交由它去处理,我们在业务层无需考虑保活和断连后重连问题;
  • 这里我们没有将获取单件的函数DB设计为不带参数的函数,而是将其设计为携带可变参数列表的函数,这主要是考虑在初次调用DB函数时,可以对底层的连接池进行设置(MaxIdleConn、MaxLifetime、MaxOpenConn)。其他情况使用时,无需传入任何参数;当然由于返回的是gorm.DB的指针,因此外层也是可以基于该指针自行设置连接池的,但在业务层动态更改连接池属性似乎并不可取;
  • 谈到可变参数函数,这里使用了功能选项(functional option)的设计,更多关于Go语言变长参数的妙用,可以参考我的专栏文章《变长参数函数的妙用》

接下来,我们再看看reader和updater对单件函数db.DB的使用,以reader为例:

// github.com/bigwhite/experiments/blob/master/database-singleton/pkg/reader/reader.go
package reader

import (
    "log"
    "time"

    "github.com/bigwhite/testdboper/pkg/db"
    "github.com/bigwhite/testdboper/pkg/model"
)

func dumpEmployee() {
    var rs []model.Employee // rs: record slice
    d := db.DB()
    d.Find(&rs)
    log.Println(rs)
}

func Run(quit <-chan struct{}) {
    tk := time.NewTicker(5 * time.Second)
    for {
        select {
        case <-tk.C:
            dumpEmployee()

        case <-quit:
            return
        }
    }
}

我们看到,reader的Run函数通过定时器每隔5s读取数据库表employee的内容,并输出。dumpEmployee函数通过db.DB非常容易的获取到访问数据库的实例,再也无需自行管理数据库的打开和关闭操作了。

最后,我们说一下DB连接的释放。我们在上面的代码中并没有看到显式的db连接的释放,因此在这样的程序中,始终都需要访问和操作数据库。释放db连接的时候,也是程序退出的时候,当进程退出,与db之间的连接会自动释放,因此无需再显式释放。

注:对于mysql而言,我们可以通过下面命令查看数据库的当前连接数:

mysql> show status like  'Threads%';
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_cached    | 2     |
| Threads_connected | 3     |
| Threads_created   | 5     |
| Threads_running   | 2     |
+-------------------+-------+
4 rows in set (0.00 sec)

以上示例代码可以在这里 https://github.com/bigwhite/experiments/tree/master/database-singleton 下载 。

附录

  • mysql安装设置(on ubuntu)
// ubuntu 18.04, mysql 5.7.33

安装mysql:

$apt-get install mysql-server mysql-client

查看mysql安装成功与否:

$ps -ef|grep mysql
mysql    23965     1  0 22:55 ?        00:00:00 /usr/sbin/mysqld --daemonize --pid-file=/run/mysqld/mysqld.pid

设置root密码:

$cat /etc/mysql/debian.cnf

# Automatically generated for Debian scripts. DO NOT TOUCH!
[client]
host     = localhost
user     = debian-sys-maint
password = xxxxxxxxxx
socket   = /var/run/mysqld/mysqld.sock

使用debian-sys-maint/xxxxxxxxxx 登录数据库:

$mysql -u debian-sys-maint -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 4
Server version: 5.7.33-0ubuntu0.18.04.1 (Ubuntu)

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> use mysql;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> update user set authentication_string=PASSWORD("root123") where user='root';
Query OK, 1 row affected, 1 warning (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 1

mysql> update user set plugin="mysql_native_password";
Query OK, 1 row affected (0.00 sec)
Rows matched: 4  Changed: 1  Warnings: 0

mysql> flush privileges;
Query OK, 0 rows affected (0.01 sec)

root密码生效:

重启mysql服务后,root密码才能生效。

$systemctl restart mysql.service
  • demo1数据和employee表的创建
>create database demo1;
> CREATE TABLE `employee` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `age` int NOT NULL,
  `gender` varchar(8) NOT NULL,
  `birthday` char(14) NOT NULL,
  `email` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

“Gopher部落”知识星球开球了!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!星球首开,福利自然是少不了的!2020年年底之前,8.8折(很吉利吧^_^)加入星球,下方图片扫起来吧!

Go技术专栏“改善Go语⾔编程质量的50个有效实践”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订阅!目前该技术专栏正在新春促销!关注我的个人公众号“iamtonybai”,发送“go专栏活动”即可获取专栏专属优惠码,可在订阅专栏时抵扣20元哦。

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商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
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

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

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 AI原生开发工作流实战 Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系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