标签 Kubernetes 下的文章

Service Weaver:以单体形式编码,以微服务形式部署

本文永久链接 – https://tonybai.com/2023/10/09/service-weaver-coding-in-monolithic-deploy-in-microservices

分布式应用的主流架构模式演化为微服务架构已经有些年头了。微服务、DevOps、持续交付和容器技术(k8s)是构成最初云原生概念的核心要素。它们相生相拌,共同演进,并推动了云计算全面进入云原生时代

云原生应用普遍采用微服务架构,遗留的单体应用程序会逐步演进并拆分为多个微服务,新应用则会直接采用微服务架构进行设计与实现。微服务的好处是显而易见的:

  • 每个微服务都编译为一个二进制文件并独立部署和扩展,可以提高资源利用率;
  • 一个微服务的崩溃不会影响到其他微服务,限制了错误的传播半径,从而提高了容错能力;
  • 改善了抽象的边界。微服务需要清晰明确的API,降低了代码纠缠不清的可能性;
  • 灵活部署,不同微服务的二进制文件可以以不同频率发布,从而实现更敏捷的代码升级。
  • … …

不过做过微服务的朋友都知道,微服务架构带来的不仅仅是好处,还有很多挑战:

  • 单体应用内的模块间可通过内存直接交互,而在微服务架构的应用中,多个微服务需要进行跨进程跨机器的通信,对数据的序列化和反序列化操作必不可少,其开销很难避免,对应用性能是有较大损耗的;
  • 研究表明,三分之二的故障是由于不同版本的微服务之间的交互引发的,这会损害应用的正确性;
  • 每个微服务开发人员都有自己的发布和管理计划,而无法像单体应用那样使用单个二进制文件来统一构建、测试和部署,这给微服务开发管理带来了很高的复杂性;
  • API管理变得复杂。一旦某个微服务发布了,它的API很难在不影响其他使用该API的服务的情况下进行变更,新老API同时存在是常态;
  • 减慢了应用程序开发的速度。在进行会影响多个微服务的更改时,开发人员无法原子地实现和部署这些更改。他们必须仔细计划如何根据自己的发布时间表在n个微服务中引入变更;
    … …

由此可见,微服务并非“银弹”,人们在消除微服务的缺点方面做了很多工作,不可谓不努力,但收效甚微,甚至出现了回归monolith(大单体)的现象

今年年初Google发布了一个在这方面的探索成果:Service Weaver。Service Weaver不仅仅是一个分布式应用的开发框架,更是一个旨在减少或消除微服务弊端的探索实验的结论。

Service Weaver到底有何与众不同?它的核心抽象是什么?它的最大优点又是什么呢?在这一篇文章中,我就和大家一起来学习和了解一下Service Weaver这个开发框架。

1. Service Weaver简介

Service Weaver是Google开源的一个编程框架(programming framework) ,用于编写、部署和管理用Go开发的分布式应用程序。

注:随着Service Weaver的演进,后续可能会有其他语言的版本。

使用Service Weaver,你可以像编写在本地机器上运行的传统单进程Go可执行文件一样编写应用程序。然后,将其部署到云中,该框架会将其分解为一组微服务,并将其与云提供商(主要是k8s)集成(如监控、跟踪、日志等)。简单来说,就是“以单体形式编码,以微服务形式部署”

开篇提过,Google开源的Service Weaver本就是为解决微服务架构在实践中出现的诸多问题而提出的创新思路与实验,为此它提出并实现了三个核心原则

  • 在构建阶段,开发人员只需编写模块化的单体程序;
  • 在首次部署和运行阶段,Service Weaver会将逻辑组件分配给物理进程,可以是本地的一个进程,也可以是多个进程,当然最主流的还是分配给运行在公有云提供商k8s的不同pod;
  • 以原子方式升级变更应用,彻底杜绝应用的不同版本间的交互。

这么说依然很抽象,闻名不如见面,接下来我们就用一些例子来看一下Service Weaver是如何践行这三个原则的。

我们先来看看用Service Weaver开发的“Hello, World”程序长什么样子。

2. Hello, World

安装Service Weaver很简单,只需执行下面命令:

$go install github.com/ServiceWeaver/weaver/cmd/weaver@latest

$weaver
USAGE

  weaver generate                 // weaver code generator
  weaver version                  // show weaver version
  weaver single    <command> ...  // for single process deployments
  weaver multi     <command> ...  // for multiprocess deployments
  weaver ssh       <command> ...  // for multimachine deployments
  weaver gke       <command> ...  // for GKE deployments
  weaver gke-local <command> ...  // for simulated GKE deployments
  weaver kube      <command> ...  // for vanilla Kubernetes deployments

DESCRIPTION

  Use the "weaver" command to deploy and manage Weaver applications.

  The "weaver generate", "weaver version", "weaver single", "weaver multi", and
  "weaver ssh" subcommands are baked in, but all other subcommands of the form
  "weaver <deployer>" dispatch to a binary called "weaver-<deployer>".
  "weaver gke status", for example, dispatches to "weaver-gke status".

注:Weaver要求Go版本高于1.21。另外在MacOS上安装使用时,官方文档提到要开启export CGO_ENABLED=1; export CC=gcc; 不过CGO_ENABLED=1通常是默认的。另外我使用CC=clang也可以正常安装和使用weaver。

安装完Weaver后,我们就来看一个基于Weaver的Hello, World示例,了解一下基于Weaver框架开发的应用的基本结构。

我们创建一个hello目录,然后在hello下面使用go mod init hello来初始化一个go module。这个例子非常简单,hello目录下只有一个main.go:

// serviceweaver-examples/hello/main.go

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/ServiceWeaver/weaver"
)

func main() {
    if err := weaver.Run(context.Background(), serve); err != nil {
        log.Fatal(err)
    }
}

// app is the main component of the application. weaver.Run creates
// it and passes it to serve.
type app struct {
    weaver.Implements[weaver.Main]
}

// serve is called by weaver.Run and contains the body of the application.
func serve(context.Context, *app) error {
    fmt.Println("Hello, World")
    return nil
}

我们看到:示例导入了weaver包,然后在main函数中调用weaver.Run函数。Run函数的原型如下:

// github.com/ServiceWeaver/weaver/weaver.go
func Run[T any, P PointerToMain[T]](ctx context.Context, app func(context.Context, *T) error) error

weaver充分利用了Go 1.18引入的泛型,Run就是一个泛型函数,它的第二个参数为app,这是一个函数类型的参数。顾名思义,app这个函数封装了整个应用的主运行逻辑。在hello这个示例中,我们为Run的第二个参数传入的是serve。而serve的逻辑非常简单,就是输出“Hello, World”,然后就返回nil了,返回nil表示正常退出。weaver.Run会处理应用的生命周期,比如优雅关闭等,serve函数就只需要关心业务逻辑即可,通过这种方式,通用的服务框架代码和业务代码便分离开来,降低了耦合,提高可维护性。

到这里,很多读者可能注意到了:由于示例过于简单,serve函数并没有使用传入的第二个参数(类型为*app),但在用Weaver开发的实用程序中,Run的第二个参数是整个应用的核心,并且app这个类型恰好就是weaver.Run泛型函数中T的类型实参(type argument)。

Run函数的注释中明确说明:T类型(app)必须是一个struct类型且包含一个weaver.Implements[weaver.Main]的嵌入字段,在该示例中app类型的定义恰是如此:

// serviceweaver-examples/hello/main.go
type app struct {
    weaver.Implements[weaver.Main]
}

说到这里,就不得不提到Service Weaver的核心抽象:组件(component)了!基于Service Weaver框架开发的应用是由一个组件的集合。实际上,Weaver中的组件就是一个普通Go接口的实现,编写代码时,组件间的交互也是通过接口的方法调用完成的。

那么,上面示例中的组件在哪里呢?上面的示例仅包含一个Weaver应用必须的组件:main组件。app类型就理解为一个main组件,它通过嵌入weaver.Implements[weaver.Main]这个类型实现了weaver.Main接口:

// Main is the interface implemented by an application's main component.
type Main interface{}

对于Weaver应用而言,main组件是不可获取的,如果注释掉app结构体类型中weaver.Implements[weaver.Main]这一行,那么无论执行weaver generate命令还是go run命令,你得到的都会是错误:

$weaver generate .
-: # hello
./main.go:12:22: *app does not satisfy "github.com/ServiceWeaver/weaver".PointerToMain[app] (missing method implements)
/Users/tonybai/Test/Go/service-weaver/hello/main.go:12:12: *app does not satisfy "github.com/ServiceWeaver/weaver".PointerToMain[app] (missing method implements)

$go run .
# hello
./weaver_gen.go:34:40: cannot use (*app)(nil) (value of type *app) as "github.com/ServiceWeaver/weaver".InstanceOf["github.com/ServiceWeaver/weaver".Main] value in variable declaration: *app does not implement "github.com/ServiceWeaver/weaver".InstanceOf["github.com/ServiceWeaver/weaver".Main] (missing method implements)
./weaver_gen.go:37:25: cannot use (*app)(nil) (value of type *app) as "github.com/ServiceWeaver/weaver".Unrouted value in variable declaration: *app does not implement "github.com/ServiceWeaver/weaver".Unrouted (missing method routedBy)
./main.go:12:22: *app does not satisfy "github.com/ServiceWeaver/weaver".PointerToMain[app] (missing method implements)

好了,大致了解Weaver应用的结构后,我们来运行一下这个示例:

$go mod tidy
go: finding module for package github.com/ServiceWeaver/weaver
go: found github.com/ServiceWeaver/weaver in github.com/ServiceWeaver/weaver v0.21.2
go: downloading modernc.org/ccgo/v3 v3.16.13
go: downloading modernc.org/cc/v3 v3.40.0
go: downloading lukechampine.com/uint128 v1.2.0
go: downloading modernc.org/token v1.0.1

$weaver generate .
$go run .
╭───────────────────────────────────────────────────╮
│ app        : hello                                │
│ deployment : ca0fcdf2-d9bc-456b-a668-159688e3cca5 │
╰───────────────────────────────────────────────────╯
Hello, World

我们看到,在go run执行之前,我们通过weaver generate命令生成一些代码,这些生成的代码放在了weaver_gen.go中,有100多行,是weaver应用运行所必须的stub代码。

hello, world虽然简单易懂,但对Weaver的核心抽象:逻辑组件(component)的体现并不明显,我们再来看一个复杂一些的例子。

3. 一个http服务器例子

我们来实现一个http服务器的例子,下面是这个例子的组件逻辑拓扑结构:

从图中可以看到,这个实例程序一共有三个weaver component:main组件(listener)、reverser组件(用于将输入的字符串反转)和converter组件(用于将输入的字符串变成大写字符串)。

reverser组件和converter组件都比较简单,每个组件对应的接口仅有一个方法,它们的代码如下:

// serviceweaver-examples/httpserver/reverser.go

package main

import (
    "context"

    "github.com/ServiceWeaver/weaver"
)

// Reverser component.
type Reverser interface {
    Reverse(context.Context, string) (string, error)
}

// Implementation of the Reverser component.
type reverser struct {
    weaver.Implements[Reverser]
}

func (r *reverser) Reverse(_ context.Context, s string) (string, error) {
    runes := []rune(s)
    n := len(runes)
    for i := 0; i < n/2; i++ {
        runes[i], runes[n-i-1] = runes[n-i-1], runes[i]
    }
    return string(runes), nil
}

// serviceweaver-examples/httpserver/converter.go

package main

import (
    "context"
    "strings"

    "github.com/ServiceWeaver/weaver"
)

// Converter component.
type Converter interface {
    ToUpper(context.Context, string) (string, error)
}

// Implementation of the Converter component.
type converter struct {
    weaver.Implements[Converter]
}

func (r *converter) ToUpper(_ context.Context, s string) (string, error) {
    return strings.ToUpper(s), nil
}

接下来,我们实现这个示例的实现weaver.Main接口的app类型:

// serviceweaver-examples/httpserver/main.go

type app struct {
    weaver.Implements[weaver.Main]
    reverser  weaver.Ref[Reverser]
    converter weaver.Ref[Converter]
    lis       weaver.Listener
}

这里app结构体类型通过weaver.Ref嵌入了实现了另外两个组件接口的组件实例,Ref函数的定义如下:

// Ref[T] is a field that can be placed inside a component implementation
// struct. T must be a component type. Service Weaver will automatically
// fill such a field with a handle to the corresponding component.
type Ref[T any] struct {
    value T
}

// Get returns a handle to the component of type T.
func (r Ref[T]) Get() T { return r.value }

此外,通过泛型类型Ref的Get方法,可以获得对相应组件的访问权。

app结构体类型中还包含了一个weaver.Listener类型的实例,Listener理论上并非组件,而是Weaver框架提供了网络服务端口监听的实现,可以放置在任何提供网络服务的组件实现内部,比如本示例的app这个main组件。app将reverser、converter和listener聚合在一起,为后续的serve函数实现提供支持。

接下来,我们看看serve函数的实现:

// serviceweaver-examples/httpserver/main.go

func serve(ctx context.Context, app *app) error {
    // The lis listener will listen on a random port chosen by the operating
    // system. This behavior can be changed in the config file.
    fmt.Printf("http listener available on %v\n", app.lis)

    // Serve the /reverse endpoint.
    http.HandleFunc("/reverse", func(w http.ResponseWriter, r *http.Request) {
        name := r.URL.Query().Get("name")
        if name == "" {
            name = "World"
        }
        reversed, err := app.reverser.Get().Reverse(ctx, name)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "after reversing, name is %s\n", reversed)
    })
    // Serve the /convert endpoint.
    http.HandleFunc("/convert", func(w http.ResponseWriter, r *http.Request) {
        name := r.URL.Query().Get("name")
        if name == "" {
            name = "World"
        }
        converted, err := app.converter.Get().ToUpper(ctx, name)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "after converting, name is %s\n", converted)
    })
    return http.Serve(app.lis, nil)
}

我们看到serve函数定义了两个端点/reverse和/convert的Handler函数,并通过http.Serve启动了一个http服务器,http服务器返回,应用退出,否则http服务将一直运行。

我们来运行一下这个程序:

$cd serviceweaver-examples/httpserver
$go mod tidy
$weaver generate .
$go run .
╭───────────────────────────────────────────────────╮
│ app        : httpserver                           │
│ deployment : 55827837-896f-4060-88c2-f1f1d953d142 │
╰───────────────────────────────────────────────────╯
http listener available on [::]:59493

我们看到,示例中的httpserver启动后在59493这个端口监听客户端的连接,我们用curl工具来测试一下:

$curl "http://localhost:59493/convert?name=abcdefg"
after converting, name is ABCDEFG
$curl  "http://localhost:59493/reverse?name=abcdefg"
after reversing, name is gfedcba

我们看到,无论是reverser组件还是converter组件工作都正常。

由于我们没有指定端口,59493是一个随机端口。如果要指定监听的地址和端口,我们可以借助weaver提供的toml格式的配置文件来实现:

// weaver.toml
[single]
listeners.lis = {address = "localhost:8080"}

基于weaver.toml配置文件启动httpserver的命令如下:

$SERVICEWEAVER_CONFIG=weaver.toml go run .
╭───────────────────────────────────────────────────╮
│ app        : httpserver                           │
│ deployment : ee49694c-4935-4f44-96f3-cc7d1d0167ae │
╰───────────────────────────────────────────────────╯
http listener available on 127.0.0.1:8080

在这种模式下启动的httpserver,所有组件都会在一个单一的进程中,组件间的通信通过方法调用进行。这种单体程序在单个进程中部署运行的方式称为single process部署模式,十分适合开发者对程序的开发与调试。weaver为这种方式提供了专门的子命令single,我们可以通过single命令在单进程启动httpserver,不过我们要修改一下weaver.toml:

// weaver.toml

[single]
listeners.lis = {address = "localhost:8080"}

[serviceweaver]
binary = "./httpserver"

无论是single子命令,还是后面即将讲到的multi,都是基于一个可执行文件进行的,因此我们要将httpserver这个示例编译为一个可执行文件”httpserver”,我已经将编译命令放入Makefile,大家输入make命令执行即可。

有了可执行的二进制文件httpserver后,我们就可以使用single子命令启动单进程版的httpserver了:

$weaver single deploy weaver.toml
╭───────────────────────────────────────────────────╮
│ app        : httpserver                           │
│ deployment : ad7c0341-d5d2-4182-8944-306d7682e708 │
╰───────────────────────────────────────────────────╯
http listener available on 127.0.0.1:8080

在开篇讲Service Weaver的三个核心原则时提到,基于Weaver的应用既可以跑在一个进程中,也可以部署在多个进程,以及云提供商的k8s环境中,下面我们就来看看weaver应用的部署,先来将单进程部署模式改为本地多进程部署模式。

4. 部署

基于Weaver应用的部署方式与编码完全解耦,我们无需修改源码便可以实现多进程部署。唯一要做的就是改改weaver.toml,新增多进程部署模式下应用的监听地址信息:

// weaver.toml
[single]
listeners.lis = {address = "localhost:8080"}

[serviceweaver]
binary = "./httpserver"

[multi]
listeners.lis = {address = "localhost:8080"} // 新增

接下来使用下面命令,我们就可以将httpserver以多进程的形式启动起来:

$weaver multi deploy weaver.toml
╭───────────────────────────────────────────────────╮
│ app        : httpserver                           │
│ deployment : bd689290-4929-47f1-a0f0-774d5e1a9307 │
╰───────────────────────────────────────────────────╯
S1003 18:51:02.042859 stdout               ac04576d                      │ http listener available on 127.0.0.1:8080
S1003 18:51:02.043210 stdout               c03c4eed                      │ http listener available on 127.0.0.1:8080

weaver multi子命令提供了查看httpserver多进程启动后状态的方法:

$weaver multi status
╭──────────────────────────────────────────────────────────╮
│ DEPLOYMENTS                                              │
├────────────┬──────────────────────────────────────┬──────┤
│ APP        │ DEPLOYMENT                           │ AGE  │
├────────────┼──────────────────────────────────────┼──────┤
│ httpserver │ bd689290-4929-47f1-a0f0-774d5e1a9307 │ 1m3s │
╰────────────┴──────────────────────────────────────┴──────╯
╭───────────────────────────────────────────────────────────────╮
│ COMPONENTS                                                    │
├────────────┬────────────┬──────────────────────┬──────────────┤
│ APP        │ DEPLOYMENT │ COMPONENT            │ REPLICA PIDS │
├────────────┼────────────┼──────────────────────┼──────────────┤
│ httpserver │ bd689290   │ weaver.Main          │ 30194, 30195 │
│ httpserver │ bd689290   │ httpserver.Converter │ 30198, 30199 │
│ httpserver │ bd689290   │ httpserver.Reverser  │ 30196, 30197 │
╰────────────┴────────────┴──────────────────────┴──────────────╯
╭─────────────────────────────────────────────────────╮
│ LISTENERS                                           │
├────────────┬────────────┬──────────┬────────────────┤
│ APP        │ DEPLOYMENT │ LISTENER │ ADDRESS        │
├────────────┼────────────┼──────────┼────────────────┤
│ httpserver │ bd689290   │ lis      │ 127.0.0.1:8080 │
╰────────────┴────────────┴──────────┴────────────────╯

在status输出的信息中,我们能看到deployment(部署)信息、组件(components)信息以及listener信息。从组件信息来看,weaver multi子命令将每个component放入了一个单独进程,包括main component,并且每个component的副本数(replica)为2,即一共启动了6个进程。从下面ps命令的输出结果也能印证这点:

$ps -ef|grep httpserver
  501 30194 30193   0  6:51下午 ttys006    0:00.05 /Users/tonybai/test/go/service-weaver/httpserver/httpserver
  501 30195 30193   0  6:51下午 ttys006    0:00.05 /Users/tonybai/test/go/service-weaver/httpserver/httpserver
  501 30196 30193   0  6:51下午 ttys006    0:00.07 /Users/tonybai/test/go/service-weaver/httpserver/httpserver
  501 30197 30193   0  6:51下午 ttys006    0:00.04 /Users/tonybai/test/go/service-weaver/httpserver/httpserver
  501 30198 30193   0  6:51下午 ttys006    0:00.05 /Users/tonybai/test/go/service-weaver/httpserver/httpserver
  501 30199 30193   0  6:51下午 ttys006    0:00.04 /Users/tonybai/test/go/service-weaver/httpserver/httpserver

在multi process这种模式下,应用的各个组件由于不在同一进程内,它们之间的通信由基于方法调用改为了基于RPC调用的方式。

weaver multi还提供了以web形式查看应用运行状态的命令:dashboard

$weaver multi dashboard
Dashboard available at: http://127.0.0.1:62183

weaver multi dashboard命令会自动打开浏览器并展示httpserver的各种运行信息和状态信息:

点击页面上的Deployment超链接,我们将进入到下面的页面中:

除此之外,页面最下方还有一个展示组件拓扑以及组件间traffic的图:

通过上图我们知道,reverse端点和convert端点分别接到过2次和1次请求。

注:web状态页面上的traces由于没有开启trace,会暂无数据。

和weaver multi一样,weaver ssh可以实现多机器部署,weaver kube实现基于k8s的部署,weaver gke实现在Google Kubernetes Engine上的部署,这里的multi、ssh、kube等都可以称为deployer。single、multi、ssh是weaver内置支持的,而其他weaver 则是调用weaver-完成的,比如:weaver gke status将调用weaver-gke status命令。

注:由于手里没有现成的kubernetes环境,weaver kube命令无法展示了。

到这里,我们已经践行了Service Weaver的两大核心原则:开发阶段以单体程序形式编码开发,以及运行时通过不同deployer(multi、ssh、k8s等)来实现部署环境与代码的解耦。到这里,你是否体会到了本文题目“以单体形式编码,以微服务形式部署”的深意了呢!

下面我们再来看看Weaver核心原则的第3条:原子升级。

5. 升级

对于使用go run或weaver multi deployment部署的应用程序来说,避免升级过程中的跨版本通信是轻而易举的事,因为每个部署都是独立运行的。

我本地没有Kubernetes环境,也没有GKE的账号,那么如何验证weaver的原子升级过程呢?好在weaver提供了gke-local,即在本地建立一个模拟gke环境,我们可以使用这种方式来看看通过weaver如何实现app的原子升级。

首先我们要执行下面命令单独安装weaver-gke-local:

$go install github.com/ServiceWeaver/weaver-gke/cmd/weaver-gke-local@latest

在我的机器和网络环境下,这个安装过程略显“漫长”,因为要拉取很多依赖的go module,还包括像k8s、k8s client这样的go module。

安装好weaver-gke-local后,我们基于httpserver建立一个新module:httpserver-upgrade。然后修改其weaver.toml,增加gke和rollout相关配置:

// serviceweaver-examples/httpserver-upgrade/weaver.toml

[single]
listeners.lis = {address = "localhost:8080"}

[serviceweaver]
binary = "./httpserver"
rollout = "5m" # Perform five minutes slow rollout.

[multi]
listeners.lis = {address = "localhost:8080"}

[gke]
regions = ["us-west1"]
listeners.lis = {public_hostname = "hello.com"}

然后,为了区分不同版本,我在main.go中为各个端点的处理handler加上了一些带有版本信息的日志,并重新执行make构建新的可执行文件。

下面我们就在gke-local环境下首次部署httpserver:

$weaver gke-local deploy weaver.toml
Deploying the application... Done
Version "b343b4de-bb84-4bd7-8bc0-09eb0054b07d" of app "httpserver" started successfully.
Note that stopping this binary will not affect the app in any way.
Tailing the logs...
S1004 06:33:14.621470 stdout               ea68b26c                      │ http v1 listener available on http://localhost:8000
S1004 06:33:14.627226 stdout               be97798d                      │ http v1 listener available on http://localhost:8000

我们可以ctrl+c结束weaver gke-local deploy这个命令的执行,但一旦部署成功,即便这个命令退出,已经部署的程序依然会运行。

^CTo continue watching the logs, run the following command:

    weaver gke-local logs --follow 'version == "b343b4de"'

并且按照上述提示,我们可以继续执行下面命令来tail整个应用的输出日志:

$weaver gke-local logs --follow 'version == "b343b4de"'
S1004 06:33:14.621470 stdout               ea68b26c                      │ http v1 listener available on http://localhost:8000
S1004 06:33:14.627226 stdout               be97798d                      │ http v1 listener available on http://localhost:8000

和multi子命令在本地多进程部署一样,在gke-local下部署后,我们也可以使用status查看应用部署信息和状态:

$weaver gke-local status
╭────────────────────────────────────────────────────────────────────╮
│ Deployments                                                        │
├────────────┬──────────────────────────────────────┬───────┬────────┤
│ APP        │ DEPLOYMENT                           │ AGE   │ STATUS │
├────────────┼──────────────────────────────────────┼───────┼────────┤
│ httpserver │ b343b4de-bb84-4bd7-8bc0-09eb0054b07d │ 4m55s │ ACTIVE │
╰────────────┴──────────────────────────────────────┴───────┴────────╯
╭─────────────────────────────────────────────────────────────────────╮
│ COMPONENTS                                                          │
├────────────┬────────────┬──────────┬──────────────────────┬─────────┤
│ APP        │ DEPLOYMENT │ LOCATION │ COMPONENT            │ HEALTHY │
├────────────┼────────────┼──────────┼──────────────────────┼─────────┤
│ httpserver │ b343b4de   │ us-west1 │ weaver.Main          │ 2/2     │
│ httpserver │ b343b4de   │ us-west1 │ httpserver.Converter │ 2/2     │
│ httpserver │ b343b4de   │ us-west1 │ httpserver.Reverser  │ 2/2     │
╰────────────┴────────────┴──────────┴──────────────────────┴─────────╯
╭─────────────────────────────────────────────────────────────────────────────────────────────╮
│ TRAFFIC                                                                                     │
├───────────┬────────────┬────────────┬────────────┬──────────┬────────────┬──────────────────┤
│ HOST      │ VISIBILITY │ APP        │ DEPLOYMENT │ LOCATION │ ADDRESS    │ TRAFFIC FRACTION │
├───────────┼────────────┼────────────┼────────────┼──────────┼────────────┼──────────────────┤
│ hello.com │ public     │ httpserver │ b343b4de   │ us-west1 │ [::]:62559 │ 0.5              │
│ hello.com │ public     │ httpserver │ b343b4de   │ us-west1 │ [::]:62564 │ 0.5              │
╰───────────┴────────────┴────────────┴────────────┴──────────┴────────────┴──────────────────╯
╭────────────────────────────╮
│ ROLLOUT OF httpserver      │
├─────────────────┬──────────┤
│                 │ us-west1 │
├─────────────────┼──────────┤
│ TIME            │ b343b4de │
│ Oct  3 22:37:59 │ 1.00     │
╰─────────────────┴──────────╯

我们看到整个应用被模拟部署到us-west1 region,每个组件有两个副本,用ps命令查看,我们也能看到6个进程:

$ps -ef|grep httpserver
  501 38480 35224   0  6:33上午 ttys006    0:00.13 /Users/tonybai/test/go/service-weaver/httpserver-upgrade/httpserver
  501 38481 35224   0  6:33上午 ttys006    0:00.11 /Users/tonybai/test/go/service-weaver/httpserver-upgrade/httpserver
  501 38482 35224   0  6:33上午 ttys006    0:00.10 /Users/tonybai/test/go/service-weaver/httpserver-upgrade/httpserver
  501 38483 35224   0  6:33上午 ttys006    0:00.10 /Users/tonybai/test/go/service-weaver/httpserver-upgrade/httpserver
  501 38484 35224   0  6:33上午 ttys006    0:00.10 /Users/tonybai/test/go/service-weaver/httpserver-upgrade/httpserver
  501 38485 35224   0  6:33上午 ttys006    0:00.10 /Users/tonybai/test/go/service-weaver/httpserver-upgrade/httpserver

现在我们可以使用curl命令来验证一下应用的可用性:

$curl  --header 'Host: hello.com' "http://localhost:8000/reverse?name=abcdefg"
after reversing-v1, name is gfedcba
$curl  --header 'Host: hello.com' "http://localhost:8000/reverse?name=abcdefg"
after reversing-v1, name is gfedcba
$curl  --header 'Host: hello.com' "http://localhost:8000/reverse?name=abcdefg"
after reversing-v1, name is gfedcba
$curl  --header 'Host: hello.com' "http://localhost:8000/convert?name=abcdefg"
after converting-v1, name is ABCDEFG
$curl  --header 'Host: hello.com' "http://localhost:8000/convert?name=abcdefg"
after converting-v1, name is ABCDEFG

可以看到,app工作正常!

此外,我们还可以通过dashboard可以以图形化的方式观测app状态(weaver gke-local dashboard),在后续升级过程中,通过dashboard可以清楚地看到整个升级过程:

注:gke-local会在本地建立一个模拟load balancer,并将发到hello.com主机的请求按Traffic Fraction分发给不同副本。

接下来,我们就来开发httpserver的v2版本,将main.go中的version改为v2,然后重新编译httpserver,执行下面命令部署新版httpserver:

$weaver gke-local deploy weaver.toml
Deploying the application... Done
Version "2ee38e73-323f-4b42-b115-ee5bc40a8c09" of app "httpserver" started successfully.
Note that stopping this binary will not affect the app in any way.
Tailing the logs...
S1004 06:50:12.575585 stdout               702058ba                      │ http v2 listener available on http://localhost:8000
S1004 06:50:12.586352 stdout               ef3d7c3f                      │ http v2 listener available on http://localhost:8000

^CTo continue watching the logs, run the following command:

    weaver gke-local logs --follow 'version == "2ee38e73"'

由于我们配置的rollout为5分钟,所以新版httpserver替换掉旧版httpserver的过程会持续5分钟。而这个过程中load balancer针对新旧两个版本的Traffic Fraction也会动态调整:旧版本会逐渐降低,新版本会逐渐升高:



这时向app发送的请求,既可能由v1版本处理,也可能由v2版本处理:

$curl  --header 'Host: hello.com' "http://localhost:8000/convert?name=abcdefg"
after converting-v1, name is ABCDEFG
$curl  --header 'Host: hello.com' "http://localhost:8000/reverse?name=abcdefg"
after reversing-v1, name is gfedcba
$curl  --header 'Host: hello.com' "http://localhost:8000/convert?name=abcdefg"
after converting-v1, name is ABCDEFG
$curl  --header 'Host: hello.com' "http://localhost:8000/reverse?name=abcdefg"
after reversing-v2, name is gfedcba

最后新版app将全面接手对请求的处理:

之后,旧版的app将被delete掉:

这样新版app的升级部署(rollout)就结束了!rollout后,所有请求将被v2版本处理,应答中将带有v2字样。

在新版本升级的过程中,你如果使用ps查看httpserver进程数量,你会发现数量多出一倍,那是因为整个rollout过程采用的是蓝绿部署方式,即完全部署一套新app,然后通过调整load balancer的分发比例,让新版app逐渐承担全部流量,而在这个过程中,不会存在新老版本组件交互的情况出现。下图展示了这一过程:

注:如果要杀掉app,可以用weaver gke-local kill httpserver命令。

6. 小结

Service Weaver是一个优秀的框架,可以帮助开发人员以单体形式快速构建、以微服务形式快速部署分布式应用,其三个核心原则的创新思路值得我们学习借鉴。

但Service Weaver也不是万能的,Service Weaver主要针对在线的分布式服务系统,即需要在用户请求到达时处理它们的系统,例如网络应用程序或API Server正是此类分布式服务系统。基于Weaver开发这类系统,应用可以轻松获取网络Listener并建立HTTP 服务器,应用可以支持原子升级,且应用组件的副本数量可以根据请求压力的大小自动扩缩(本文并未演示这个特性)。

不过要注意的是:Service Weaver仅仅开源了几个月,其API尚未Stable,本文中的示例基于v0.21.2版本实现,也许在未来的某个时间点,这些示例可能会因API的变化而无法Run起来, status命令和dashboard命令所展现给用户的样式也会发生变化。另外学习weaver本身也是有学习成本的,weaver自身的代码由于采用了泛型和反射,读起来也是很晦涩。

综上,Service Weaver所践行的理念的优秀的,但考虑其成熟度以及Go社区崇尚的“The Best Go framework is no framework”的信条,选择引入Service Weaver框架之前务必要仔细斟酌。

本文涉及的Go源码,可以在这里下载。

7. 参考资料


“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语言

本文永久链接 – https://tonybai.com/2023/02/23/learn-go-in-10-min

本文旨在带大家快速入门Go语言,期望小伙伴们在花费十分钟左右通读全文后能对Go语言有一个初步的认知,为后续进一步深入学习Go奠定基础。

本文假设你完全没有接触过Go,你可能是一名精通其他编程语言的程序员,也可能是毫无编程经验、刚刚想转行为码农的热血青年。

编程简介

编程就是生产可在计算机上执行的程序的过程(如下图)。在这个过程中,程序员是“劳动力”,编程语言是工具,可执行的程序是生产结果。而Go语言就是程序员在编程生产过程中使用的一种优秀生产工具。

作为“劳动力”的程序员在这个过程中要做的就是使用某种编程语言作为生产工具,将事先设计好的执行逻辑组织和表达出来,这与一个作家将其大脑中设计好的故事情节用人类语言组织和书写在纸上的过程颇为类似(如下图)。

通过这个类比来看,学习一门编程语言,就好比学习一门人类语言,其词汇和语法将是我们的主要学习内容,本文就将围绕Go语言的主要“词汇”和语法形式进行快速说明。

Go简介

Go语言是由Google公司的三位大神级程序员Robert Griesemer、Rob Pike和Ken Thompson在2007年共同开发的一种新的后端编程语言,2009年,Go语言宣布开源。

Go语言的特点是简单易学、静态类型、编译速度快,运行效率高,代码简洁,并且原生支持并发编程。它还支持自动内存管理,可以让开发者更加专注于编程本身,而不用担心内存泄漏的问题。此外,Go语言还支持多核处理器,可以更好地利用多核处理器的优势,提高程序的运行效率。

经过十多年的发展,Go语言现在已经成为一种流行的编程语言,它可以用于开发各种应用程序,包括Web应用、网络服务、系统管理工具、移动应用、游戏开发、数据库管理等。Go语言常用于构建大型分布式系统,以及构建高性能的服务器端应用程序。Go为当前的云原生计算时代开发了一批“杀手级”应用,包括Docker、Kubernetes、Prometheus、InfluxDB、Cilium等。

安装Go

Go是静态语言,需要先编译,再执行,因此在开发Go程序之前,我们首先需要安装Go编译器以及相关工具链。安装的步骤很简单:

  • Go官网下载最新版本的Go语言安装包 – https://go.dev/dl/
  • 解压安装包,并将其复制到您想要安装的位置,例如:/usr/local/go;如果是Windows、MacOS平台,也可以下载图形化安装的安装包;
  • 设置环境变量,将Go语言的安装路径添加到PATH变量中;
  • 打开终端,输入go version,检查Go语言是否安装成功。如输出类似下面的内容,则表明安装成功!
$go version
go version go1.20 darwin/amd64

注:位于中国大陆的开发者们还需要一个额外的设置:export GOPROXY=’https://goproxy.cn’或将这个设置置于shell配置文件(比如.bashrc)中并使之生效。

第一个Go程序:Hello World

建立一个新目录,并在其中创建新文件helloworld.go,用任意编辑器打开helloworld.go,输入下面Go源码:

//helloworld.go

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

Go支持直接运行某个源文件:

$go run helloworld.go
Hello, World!

但通常我们会先编译这个源文件(helloworld.go),生成可执行的二进制程序(./helloworld),然后再运行它:

$go build -o helloworld helloworld.go
$./helloworld
Hello, World!

Go包(package)

Go包是Go语言中的一种封装技术,它可以将一组Go语言源文件组织成一个可重用的单元,以便在其他Go程序中使用。同属于一个Go包的所有源文件放在一个目录下,并且按惯例该目录的名字与包名相同。以Go标准库的io包为例,其包内的源文件列表如下:

// $GOROOT/src/io目录下的文件列表:
io.go
multi.go
pipe.go

Go包也是Go编译的基本单元,Go编译器可以将包编译为可执行文件(如何该包为main包,且包含main函数实现),也可以编译为可重用的库文件(.a)。

包声明

Go包的声明通常是在每个Go源文件的开头,使用关键字package进行声明,例如:

// mypackage.go
package mypackage

... ...

package的名字按惯例通常为全小写的单个单词或缩略词,比如io、net、os、fmt、strconv、bytes等。

导入Go包

如果要复用已有的Go包,我们需要在源码中导入该包。要导入Go包,可以使用import关键字,例如:

import "fmt"                    // 导入标准库的fmt包

import "github.com/spf13/pflag" // 导入spf13开源的pflag包

import _ "net/http/pprof"       // 导入标准库net/http/pprof包,
                                // 但不显式使用该包中的类型、变量、函数等标识符

import myfmt "fmt"              // 将导入的包重命名为myfmt

Go模块

Go模块(module)是Go语言在1.11版本中引入的新特性,Go module是一组相关的Go package的集合,这个包集合被当做一个独立的单元进行统一版本管理。Go module这种新的依赖管理机制可以让开发者更轻松地管理Go语言项目的依赖关系,并且可以更好地支持多版本的依赖管理。在具有实用价值的Go项目中,我们都会使用Go module进行依赖管理。Go module有版本之分,Go module的版本依赖关系是建立在对语义版本(semver)严格遵守的前提下的。

Go使用go.mod文件来精确记录依赖关系要求,下面是go.mod中依赖关系的操作方法:

$go mod init demo // 创建一个module root为demo的go.mod
$go mod init github.com/bigwhite/mymodule // 创建一个module root为github.com/bigwhite/mymodule的go.mod

$go get github.com/bigwhite/foo@latest  // 向go.mod中添加一个依赖包github.com/bigwhite/foo的最新版本
$go get github.com/bigwhite/foo         // 与上面命令等价
$go get github.com/bigwhite/foo@v1.2.3  // 显式指定要获取v1.2.3版本

$go mod tidy   // 自动添加缺失的依赖包和清理不用的依赖包
$go mod verify // 确认所有依赖都有效

Go最小项目结构

Go官方并没有规定Go项目的标准结构布局,下面是Go核心团队技术负责人Russ Cox推荐的Go最小项目结构:

// 在Go项目仓库根路径下

- go.mod
- LICENSE
- README
- xx.go
- yy.go
... ...

// 在Go项目仓库根路径下

- go.mod
- LICENSE
- README
- package1/
    - package1.go
- package2/
    - package2.go
... ...

变量

Go语言有两种变量声明方式:

  • 使用var关键字

使用var关键字进行声明的方式适合所有场合。

var a int     // 声明一个int型变量a,初值为0
var b int = 5 // 声明一个int型变量b,初值为5
var c = 6     // Go会根据右值自动为变量c的赋予默认类型,默认的整型为int

var (         // 我们可以将变量声明统一放置在一个var块中,这与上面的声明方式等价
    a int
    b int = 5
    c = 6
)

注:Go变量声明采用变量在前,类型在后的方式,这与C、C++、Java等静态编程语言有较大不同。

  • 使用短声明方式声明变量
a := 5       // 声明一个变量a,Go会根据右值自动为变量a的赋予默认类型,默认的整型为int
s := "hello" // 声明一个变量s,Go会根据右值自动为变量s的赋予默认类型,默认的字符串类型为string

注:这种声明方式仅限于在函数或方法内使用,不能用于声明包级变量或全局变量。

常量

Go语言的常量使用const关键字进行声明:

const a int       // 声明一个int型常量a,其值为0
const b int = 5   // 声明一个int型常量b,其值为5
const c = 6       // 声明一个常量c,Go会根据右值自动为常量c的赋予默认类型,默认的整型为int
const s = "hello" // 声明一个常量s,Go会根据右值自动为常量s的赋予默认类型,默认的字符串类型为string

const (           // 我们可以将常量声明统一放置在一个const块中,这与上面的声明方式等价
    a int
    b int = 5
    c = 6
    s = "hello"
)

类型

Go原生内置了多种基本类型与复合类型。

基本类型

Go原生支持的基本类型包括布尔型、数值类型(整型、浮点型、复数类型)、字符串类型,下面是一些示例:

bool  // 布尔类型,默认值false

uint     // 架构相关的无符号整型,64位平台上其长度为8字节
int      // 架构相关的有符号整型,64位平台上其长度为8字节
uintptr  // 架构相关的用于表示指针值的类型,它是一个无符号的整数,大到足以存储一个任意类型的指针的值

uint8    // 架构无关的8位无符号整型
uint16   // 架构无关的16位无符号整型
uint32   // 架构无关的32位无符号整型
uint64   // 架构无关的64位无符号整型

int8     // 架构无关的8位有符号整型
int16    // 架构无关的16位有符号整型
int32    // 架构无关的32位有符号整型
int64    // 架构无关的64位有符号整型

byte     // uint8类型的别名
rune     // int32类型的别名,用于表示一个unicode字符(码点)

float32     // 单精度浮点类型,满足IEEE-754规范
float64     // 双精度浮点类型,满足IEEE-754规范

complex64   // 复数类型,其实部和虚部均为float32浮点类型
complex128  // 复数类型,其实部和虚部均为float64浮点类型

string      // 字符串类型,默认值为""

我们可以使用预定义函数complex来构造复数类型,比如:complex(1.0, -1.4)构造的复数为1 – 1.4i。

复合类型

Go原生支持的复合类型包括数组(array)、切片(slice)、结构体(struct)、指针(pointer)、函数(function)、接口(interface)、map、channel。

数组类型

数组类型是一组同构类型元素组成的连续体,它具有固定的长度(length),不能动态伸缩:

[8]int      // 一个元素类型为int、长度为16的数组类型
[32]byte    // 一个元素类型为byte、长度为32的数组类型
[2]string   // 一个元素类型为string、长度为2的数组类型
[N]T        // 一个元素类型为T、长度为N的数组类型

通过预定义函数len可以得到数组的长度:

var a = [8]int{11, 12, 13, 14, 15, 16, 17, 18}
println(len(a)) // 8

通过数组下标(从0开始)可以直接访问到数组中的任意元素:

println(a[0]) // 11
println(a[2]) // 13
println(a[7]) // 18

Go支持声明多维数组,即数组的元素类型依然为数组类型:

[2][3][5]float64  // 一个多维数组类型,等价于[2]([3]([5]float64))

切片类型

切片类型与数组类型类似,也是同构类型元素的连续体。不同的是切片类型的长度可变,我们在声明切片类型时无需传入长度属性:

[]int       // 一个元素类型为int的切片类型
[]string    // 一个元素类型为string的切片类型
[]T         // 一个元素类型为T的切片类型
[][][]float64 // 多维切片类型,等价于[]([]([]float64))

通过预定义函数len可以得到切片的当前长度:

var sl = []int{11, 12} // 一个元素类型为int的切片,其长度(len)为2, 其值为[11 12]
println(len(sl)) // 2

切片还有一个属性,那就是容量,通过预定义函数cap可以获得其容量值:

println(cap(sl)) // 2

和数组不同,切片可以动态伸缩,Go会根据元素的数量动态对切片容量进行扩展。我们可以通过append函数向切片追加元素:

sl = append(sl, 13)     // 向sl中追加新元素,操作后sl为[11 12 13]
sl = append(sl, 14)     // 向sl中追加新元素,操作后sl为[11 12 13 14]
sl = append(sl, 15)     // 向sl中追加新元素,操作后sl为[11 12 13 14 15]
println(len(sl), cap(sl)) // 5 8 追加后切片容量自动扩展为8

和数组一样,切片也是使用下标直接访问其中的元素:

println(sl[0]) // 11
println(sl[2]) // 13
println(sl[4]) // 15

结构体类型

Go的结构体类型是一种异构类型字段的聚合体,它提供了一种通用的、对实体对象进行聚合抽象的能力。下面是一个包含三个字段的结构体类型:

struct {
    name string
    age  int
    gender string
}

我们通常会给这样的一个结构体类型起一个名字,比如下面的Person:

type Person struct {
    name string
    age  int
    gender string
}

下面声明了一个Person类型的变量:

var p = Person {
    name: "tony bai",
    age: 20,
    gender: "male",
}

我们可以通过p.FieldName来访问结构体中的字段:

println(p.name) // tony bai
p.age = 21

结构体类型T的定义中可以包含类型为*T的字段成员,但不能递归包含T类型的字段成员:

type T struct {
    ... ...
    p *T    // ok
    t T     // 错误:递归定义
}

Go结构体亦可以在定义中嵌入其他类型:

type F struct {
    ... ...
}

type MyInt int

type T struct {
    MyInt
    F
    ... ...
}

嵌入类型的名字将作为字段名:

var t = T {
    MyInt: 5,
    F: F {
        ... ...
    },
}

println(t.MyInt) // 5

Go支持不包含任何字段的空结构体:

struct{}
type Empty struct{}        // 一个空结构体类型

空结构体类型的大小为0,这在很多场景下很有用(省去了内存分配的开销):

var t = Empty{}
println(unsafe.Sizeof(t)) // 0

指针类型

int类型对应的指针类型为*int,推而广之T类型对应的指针类型为*T。和非指针类型不同,指针类型变量存储的是内存单元的地址,*T指针类型变量的大小与T类型大小无关,而是和系统地址的表示长度有关。

*int     // 一个int指针类型
*[4]byte // 一个[4]byte数组指针类型

var a = 6
var p *T // 声明一个T类型指针变量p,默认值为nil
p = &a   // 用变量a的内存地址给指针变量p赋值
*p = 7   // 指针解引用,通过指针p将变量a的值由6改为7

n := new(int)  // 预定义函数返回一个*int类型指针
arr := new([4]int)  // 使用预定义函数new分配一个[4]int数组并返回一个*[4]int类型指针

map类型

map是Go语言提供的一种抽象数据类型,它表示一组无序的键值对,下面定义了一组map类型:

map[string]int                // 一个key类型为string,value类型为int的map类型
map[*T]struct{ x, y float64 } // 一个key类型为*T,value类型为struct{ x, y float64 }的map类型
map[string]interface{}        // 一个key类型为string,value类型为interface{}的map类型

我们可以用map字面量或make来创建一个map类型实例:

var m = map[string]int{}      // 声明一个map[string]int类型变量并初始化
var m1 = make(map[string]int) // 与上面的声明等价
var m2 = make(map[string]int, 100) // 声明一个map[string]int类型变量并初始化,其初始容量建议为100

操作map变量的方法也很简单:

m["key1"] = 5  // 添加/设置一个键值对
v, ok := m["key1"]  // 获取“key1”这个键的值,如果存在,则其值存储在v中,ok为true
delete(m, "key1") // 从m这个map中删除“key1”这个键以及其对应的值

其他类型

函数、接口、channel类型在后面有详细说明。

自定义类型

使用type关键字可以实现自定义类型:

type T1 int         // 定义一个新类型T1,其底层类型(underlying type)为int
type T2 string      // 定义一个新类型T2,其底层类型为string
type T3 struct{     // 定义一个新类型T3,其底层类型为一个结构体类型
    x, y int
    z string
}
type T4 []float64   // 定义一个新类型T4,其底层类型为[]float64切片类型
type T5 T4          // 定义一个新类型T5,其底层类型为[]float64切片类型

Go也支持为类型定义别名(alias),其形式如下;

type T1 = int       // 定义int的类型别名为T1,T1与int等价
type T2 = string    // 定义string的类型别名为T2,T2与string等价
type T3 = T2        // 定义T的类型别名为T3,T3与T2等价,也与string等价

类型转换

Go不支持隐式自动转型,如果要进行类型转换操作,我们必须显式进行,即便两个类型的底层类型相同也需如此:

type T1 int
type T2 int
var t1 T1
var n int = 5
t1 = T1(n)      // 显式将int类型变量转换为T1类型
var t2 T2
t2 = T2(t1)     // 显式将T1类型变量转换为T2类型

Go很多原生类型支持相互转换:

// 数值类型的相互转换

var a int16 = 16
b := int32(a)
c := uint16(a)
f := float64(a)

// 切片与数组的转换(Go 1.17版本及后续版本支持)

var a [3]int = [3]int([]int{1,2,3}) // 切片转换为数组
var pa *[3]int = (*[3]int)([]int{1,2,3}) // 切片转换为数组指针
sl := a[:] // 数组转换为切片

// 字符串与切片的相互转换

var sl = []byte{'h', 'e','l', 'l', 'o'}
var s = string(sl) // s为hello
var sl1 = []byte(s) // sl1为['h' 'e' 'l' 'l' 'o']
string([]rune{0x767d, 0x9d6c, 0x7fd4})  // []rune切片到string的转换

控制语句

Go提供了常见的控制语句,包括条件分支(if)、循环语句(for)和选择分支语句(switch)。

条件分支语句

// if ...

if a == 1 {
    ... ...
}

// if - else if - else

if a == 1 {

} else if b == 2 {

} else {

}

// 带有条件语句自用变量
if a := 1; a != 0 {

}

// if语句嵌套

if a == 1 {
    if b == 2 {

    } else if c == 3 {

    } else {

    }
}

循环语句

// 经典循环

for i := 0; i < 10; i++ {
    ...
}

// 模拟while ... do

for i < 10 {

}

// 无限循环

for {

}

// for range

var s = "hello"
for i, c := range s {

}

var sl = []int{... ...}
for i, v := range sl {

}

var m = map[string]int{}
for k, v := range m {

}

var c = make(chan int, 100)
for v := range c {

}

选择分支语句

var n = 5
switch n {
    case 0, 1, 2, 3:
        s1()
    case 4, 5, 6, 7:
        s2()
    default: // 默认分支
        s3()
}

switch n {
    case 0, 1:
        fallthrough  // 显式告知执行下面分支的动作
    case 2, 3:
        s1()
    case 4, 5, 6, 7:
        s2()
    default:
        s3()
}

switch x := f(); {
    case x < 0:
        return -x
    default:
        return x
}

switch {
    case x < y:
        f1()
    case x < z:
        f2()
    case x == 4:
        f3()
}

函数

Go使用func关键字来声明一个函数:

func greet(name string) string {
    return fmt.Sprintf("Hello %s", name)
}

函数由函数名、可选的参数列表和返回值列表组成。Go函数支持返回多个返回值,并且我们通常将表示错误值的返回类型放在返回值列表的最后面:

func Atoi(s string) (int, error) {
    ... ...
    return n, nil
}

在Go中函数是一等公民,因此函数自身也可以作为参数或返回值:

func MultiplyN(n int) func(x int) int {
  return func(x int) int {
    return x * n
  }
}

像上面MultiplyN函数中定义的匿名函数func(x int) int,它的实现中引用了它的外围函数MultiplyN的参数n,这样的匿名函数也被称为闭包(closure)

说到函数,我们就不能不提defer。在某函数F调用的前面加上defer,该函数F的执行将被“延后”至其调用者A结束之后:

func F() {
    fmt.Println("call F")
}

func A() {
    fmt.Println("call A")
    defer F()
    fmt.Println("exit A")
}

func main() {
    A()
}

上面示例输出:

call A
exit A
call F

在一个函数中可以多次使用defer:

func B() {
    defer F()
    defer G()
    defer H()
}

被defer修饰的函数将按照“先入后出”的顺序在B函数结束后被调用,上面B函数执行后将输出:

call H
call G
call F

方法

方法是带有receiver的函数。下面是Point类型的一个方法Length:

type Point struct {
    x, y float64
}

func (p Point) Length() float64 {
    return math.Sqrt(p.x * p.x + p.y * p.y)
}

而在func关键字与函数名之间的部分便是receiver。这个receiver也是Length方法与Point类型之间纽带。我们可以通过Point类型变量来调用Length方法:

var p = Point{3,4}
fmt.Println(p.Length())

亦可以将方法当作函数来用:

var p = Point{3,4}
fmt.Println(Point.Length(p)) // 这种用法也被称为方法表达式(method expression)

接口

接口是一组方法的集合,它代表一个“契约”,下面是一个由三个方法组成的方法集合的接口类型:

type MyInterface interface {
    M1(int) int
    M2(string) error
    M3()
}

Go推崇面向接口编程,因为通过接口我们可以很容易构建低耦合的应用。

Go还支持在接口类型(如I)中嵌套其他接口类型(如io.Writer、sync.Locker),其结果就是新接口类型I的方法集合为其方法集合与嵌入的接口类型Writer和Locker的方法集合的并集:

type I interface { // 一个嵌入了其他接口类型的接口类型
   io.Writer
   sync.Locker
}

接口实现

如果一个类型T实现了某个接口类型MyInterface方法集合中的所有方法,那么我们说该类型T实现了接口MyInterface,于是T类型的变量t可以赋值给接口类型MyInterface的变量i,此时变量i的动态类型为T:

var t T
var i MyInterface = t // ok

通过上述变量i可以调用T的方法:

i.M1(5)
i.M2("demo")
i.M3()

方法集合为空的接口类型interface{}被称为“空接口类型”,空白的“契约”意味着任何类型都实现了该空接口类型,即任何变量都可以赋值给interface{}类型的变量:

var i interface{} = 5 // ok
i = "demo"            // ok
i = T{}               // ok
i = &T{}              // ok
i = []T{}             // ok

注:Go 1.18中引入的新预定义标识符any与interface{}是等价类型。

接口的类型断言

Go支持通过类型断言从接口变量中提取其动态类型的值:

v, ok := i.(T) // 类型断言

如果接口变量i的动态类型确为T,那么v将被赋予该动态类型的值,ok为true;否则,v为T类型的零值,ok为false。

类型断言也支持下面这种语法形式:

v := i.(T)

但在这种形式下,一旦接口变量i之前被赋予的值不是T类型的值,那么这个语句将抛出panic。

接口类型的type switch

“type switch”这是一种特殊的switch语句用法,仅用于接口类型变量:

func main() {
    var x interface{} = 13
    switch x.(type) {
    case nil:
        println("x is nil")
    case int:
        println("the type of x is int") // 执行这一分支case
    case string:
        println("the type of x is string")
    case bool:
        println("the type of x is string")
    default:
        println("don't support the type")
    }
}

switch关键字后面跟着的表达式为x.(type),这种表达式形式是switch语句专有的,而且也只能在switch语句中使用。这个表达式中的x必须是一个接口类型变量,表达式的求值结果是这个接口类型变量对应的动态类型。

上述例子中switch后面的表达式也可由x.(type)换成了v := x.(type)。v中将存储变量x的动态类型对应的值信息:

var x interface{} = 13
switch x.(type) {
    case nil:
        println("v is nil")
    case int:
        println("the type of v is int, v =", v) // 执行这一分支case,v = 13
    ... ...
}

泛型

Go从1.18版本开始支持泛型。Go泛型的基本语法是类型参数(type parameter),Go泛型方案的实质是对类型参数的支持,包括:

  • 泛型函数(generic function):带有类型参数的函数;
  • 泛型类型(generic type):带有类型参数的自定义类型;
  • 泛型方法(generic method):泛型类型的方法。

泛型函数

下面是一个泛型函数max的定义:

type ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 |
        ~string
}

func max[T ordered](sl []T) T {
    ... ...
}

与普通Go函数相比,max函数在函数名称与函数参数列表之间多了一段由方括号括起的代码:[T ordered];max参数列表中的参数类型以及返回值列表中的返回值类型都是T,而不是某个具体的类型。

max函数中多出的[T ordered]就是Go泛型的类型参数列表(type parameters list),示例中这个列表中仅有一个类型参数T,ordered为类型参数的类型约束(type constraint)。

我们可以像普通函数一样调用泛型函数,我们可以显式指定类型实参:

var m int = max[int]([]int{1, 2, -4, -6, 7, 0})  // 显式指定类型实参为int
fmt.Println(m) // 输出:7

Go也支持自动推断出类型实参:

var m int = max([]int{1, 2, -4, -6, 7, 0}) // 自动推断T为int
fmt.Println(m) // 输出:7

泛型类型

所谓泛型类型,就是在类型声明中带有类型参数的Go类型:

type Set[T comparable] map[T]string

type element[T any] struct {
    next *element[T]
    val  T
}

type Map[K, V any] struct {
  root    *node[K, V]
  compare func(K, K) int
}

以泛型类型Set为例,其使用方法如下:

var s = Set[string]{}
s["key1"] = "value1"
println(s["key1"]) // value1

泛型方法

Go类型可以拥有自己的方法(method),泛型类型也不例外,为泛型类型定义的方法称为泛型方法(generic method)。

type Set[T comparable] map[T]string

func (s Set[T]) Insert(key T, val string) {
    s[key] = val
}

func (s Set[T]) Get(key T) (string, error) {
    val, ok := s[key]
    if !ok {
        return "", errors.New("not found")
    }
    return val, nil
}

func main() {
    var s = Set[string]{
        "key": "value1",
    }
    s.Insert("key2", "value2")
    v, err := s.Get("key2")
    fmt.Println(v, err) // value2 <nil>
}

类型约束

Go通过类型约束(constraint)对泛型函数的类型参数以及泛型函数中的实现代码设置限制。Go使用扩展语法后的interface类型来定义约束。

下面是使用常规接口类型作为约束的例子:

type Stringer interface {
    String() string
}

func Stringify[T fmt.Stringer](s []T) (ret []string) { // 通过Stringer约束了T的实参只能是实现了Stringer接口的类型
    for _, v := range s {
        ret = append(ret, v.String())
    }
    return ret
}

Go接口类型声明语法做了扩展,支持在接口类型中放入类型元素(type element)信息:

type ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 | ~string
}

func Less[T ordered](a, b T) bool {
    return a < b
}

type Person struct {
    name string
    age  int
}

func main() {
    println(Less(1, 2)) // true
    println(Less(Person{"tony", 11}, Person{"tom", 23})) // Person不满足ordered的约束,会导致编译错误
}

并发

Go语言原生支持并发,Go并没有使用操作系统线程作为并发的基本执行单元,而是实现了goroutine这一由Go运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。

goroutine

通过go关键字+函数/方法的方式,我们便可以创建一个goroutine。创建后,新goroutine将拥有独立的代码执行流,并与创建它的goroutine一起被Go运行时调度。

go fmt.Println("I am a goroutine")

// $GOROOT/src/net/http/server.go
c := srv.newConn(rw)
go c.serve(connCtx)

goroutine的执行函数返回后,goroutine便退出。如果是主goroutine(执行main.main的goroutine)退出,那么整个Go应用进程将会退出,程序生命周期结束。

channel

Go提供了原生的用于goroutine之间通信的机制channel,channel的定义与操作方式如下:

// channel类型
chan T          // 一个元素类型为T的channel类型
chan<- float64  // 一个元素类型为float64的只发送channel类型
<-chan int      // 一个元素类型为int的只接收channel类型

var c chan int             // 声明一个元素类型为int的channel类型的变量,初值为nil
c1 := make(chan int)       // 声明一个元素类型为int的无缓冲的channel类型的变量
c2 := make(chan int, 100)  // 声明一个元素类型为int的带缓冲的channel类型的变量,缓冲大小为100
close(c)                   // 关闭一个channel

下面是两个goroutine基于channel通信的例子:

func main() {
    var c = make(chan int)
    go func(a, b int) {
        c <- a + b
    }(3,4)
    println(<-c) // 7
}

当涉及同时对多个channel进行操作时,Go提供了select机制。通过select,我们可以同时在多个channel上进行发送/接收操作:

select {
case x := <-ch1:     // 从channel ch1接收数据
  ... ...

case y, ok := <-ch2: // 从channel ch2接收数据,并根据ok值判断ch2是否已经关闭
  ... ...

case ch3 <- z:       // 将z值发送到channel ch3中:
  ... ...

default:             // 当上面case中的channel通信均无法实施时,执行该默认分支
}

错误处理

Go提供了简单的、基于错误值比较的错误处理机制,这种机制让每个开发人员必须显式地去关注和处理每个错误。

error类型

Go用error这个接口类型表示错误,并且按惯例,我们通常将error类型返回值放在返回值列表的末尾。

// $GOROOT/src/builtin/builtin.go
type error interface {
    Error() string
}

任何实现了error的Error方法的类型的实例,都可以作为错误值赋值给error接口变量。

Go提供了便捷的构造错误值的方法:

err := errors.New("your first demo error")
errWithCtx = fmt.Errorf("index %d is out of bounds", i)

错误处理形式

Go最常见的错误处理形式如下:

err := doSomething()
if err != nil {
    ... ...
    return err
}

通常我们会定义一些“哨兵”错误值来辅助错误处理方检视(inspect)错误值并做出错误处理分支的决策:

// $GOROOT/src/bufio/bufio.go
var (
    ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
    ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
    ErrBufferFull        = errors.New("bufio: buffer full")
    ErrNegativeCount     = errors.New("bufio: negative count")
)

func doSomething() {
    ... ...
    data, err := b.Peek(1)
    if err != nil {
        switch err {
        case bufio.ErrNegativeCount:
            // ... ...
            return
        case bufio.ErrBufferFull:
            // ... ...
            return
        case bufio.ErrInvalidUnreadByte:
            // ... ...
            return
        default:
            // ... ...
            return
        }
    }
    ... ...
}

Is和As

从Go 1.13版本开始,标准库errors包提供了Is函数用于错误处理方对错误值的检视。Is函数类似于把一个error类型变量与“哨兵”错误值进行比较:

// 类似 if err == ErrOutOfBounds{ … }
if errors.Is(err, ErrOutOfBounds) {
    // 越界的错误处理
}

不同的是,如果error类型变量的底层错误值是一个包装错误(Wrapped Error),errors.Is方法会沿着该包装错误所在错误链(Error Chain),与链上所有被包装的错误(Wrapped Error)进行比较,直至找到一个匹配的错误为止。

标准库errors包还提供了As函数给错误处理方检视错误值。As函数类似于通过类型断言判断一个error类型变量是否为特定的自定义错误类型:

// 类似 if e, ok := err.(*MyError); ok { … }
var e *MyError
if errors.As(err, &e) {
    // 如果err类型为*MyError,变量e将被设置为对应的错误值
}

如果error类型变量的动态错误值是一个包装错误,errors.As函数会沿着该包装错误所在错误链,与链上所有被包装的错误的类型进行比较,直至找到一个匹配的错误类型,就像errors.Is函数那样。

小结

读到这里,你已经对Go语言有了入门级的认知,但要想成为一名Gopher(对Go开发人员的称呼),还需要更进一步的学习与实践。我的极客时间专栏《Go语言第一课》是一个很好的起点,欢迎大家订阅学习^_^。

BTW,本文部分内容由ChatGPT生成!你能猜到是哪些部分吗^_^。


“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 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

文章

评论

  • 正在加载...

分类

标签

归档



Statcounter View My Stats