2021年12月

协程存在的原因?协程能够解决哪些问题?

在我们现在CS,BS开发模式下,服务器的吞吐量是一个很重要的参数。其实吞吐量是 IO 处理时间加上业务处理。

为了简单起见,比如,客户端与服务器之间是长连接的,客户端定期给服务器发送心跳包数据。客户端发送一次心跳包到服务器,服务器更新该新客户端状态的。心跳包发送的过程,业务处理时长等于IO读取(RECV系统调用)加上业务处理(更新客户状态)。吞吐量等于1s业务处理次数。

2023-03-25T13:13:29.png

业务处理(更新客户端状态)时间,业务不一样的,处理时间不一样,我们就不做讨论。那如何提升recv的性能。

若只有一个客户端,recv的性能也没有必要提升,也不能提升。若在有百万计的客户端长连接的情况,我们该如何提升。以Linux为例,在这里需要介绍一个“网红”就是epoll。服务器使用epoll管理百万计的客户端长连接,代码框架如下:

while (1) {
    int nready = epoll_wait(epfd, events, EVENT_SIZE, -1);
    for (i = 0;i < nready;i ++) {
        int sockfd = events[i].data.fd;
        if (sockfd == listenfd) {
            int connfd = accept(listenfd, xxx, xxxx);
 
            setnonblock(connfd);
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = connfd;
            epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
        } else {
            handle(sockfd);
        }
    } 
}

对于响应式服务器,所有的客户端的操作驱动都是来源于这个大循环。来源于epoll_wait的反馈结果。对于服务器处理百万计的IO。Handle(sockfd)实现方式有两种。

第一种,handle(sockfd) 函数内部对 sockfd 进行读写动作。代码如下:

int handle(int sockfd) {

 recv(sockfd, rbuffer, length, 0);
 
 parser_proto(rbuffer, length);

 send(sockfd, sbuffer, length, 0);
 
}

handle 的 io 操作(send,recv)与 epoll_wait 是在同一个处理流程里面的。这就是 IO 同步操作。
优点:

  1. sockfd 管理方便。
  2. 操作逻辑清晰。

缺点:

  1. 服务器程序依赖 epoll_wait 的循环响应速度慢。
  2. 程序性能差

第二种,handle(sockfd)函数内部将 sockfd 的操作,push 到线程池中,代码如下:

int thread_cb(int sockfd) {
 // 此函数是在线程池创建的线程中运行。
 // 与 handle 不在一个线程上下文中运行
 recv(sockfd, rbuffer, length, 0);
 parser_proto(rbuffer, length);
 send(sockfd, sbuffer, length, 0);
}
int handle(int sockfd) {
 //此函数在主线程 main_thread 中运行
 //在此处之前,确保线程池已经启动。
 push_thread(sockfd, thread_cb); //将 sockfd 放到其他线程中运行。
}

Handle 函数是将 sockfd 处理方式放到另一个已经其他的线程中运行,如此做法,将 io 操作(recv,send)与 epoll_wait 不在一个处理流程里面,使得 io 操作(recv,send)与 epoll_wait 实现解耦。这就叫做 IO 异步操作。

优点:

  1. 子模块好规划。
  2. 程序性能高。

缺点:

正因为子模块好规划,使得模块之间的 sockfd 的管理异常麻烦。每一个子线程都需要管理好 sockfd,避免在 IO 操作的时候,sockfd 出现关闭或其他异常。

上文有提到 IO 同步操作,程序响应慢,IO 异步操作,程序响应快。

C 语言实现协程,最困难的部分就是上下文信息的保存和还原。这样才能够做到,让协程在任意位置让出执行权限,稍后再恢复到中断位置继续执行。C 实现协程一般有几个方案。

使用第三方库来保存恢复上下文数据,比如ucontext

使用汇编来保存上下文信息

使用setjmp / longjmp 保存恢复上下文信息

使用switch case的特性来做上下文断点继续,上下文信息需要用static变量保存。比如Protothreads

使用线程来保存上下文信息

本文,使用了switch case的特性来保存中断位置,使用数据结构和static变量来保存上下文信息,使用宏来构建API调用。由于我使用过lua和unity c#协程进行了产品开发。所以,这套实现会贴近unity中C#的使用习惯。完成了一下功能:

在协程执行的任意位置暂停,让出执行权限

恢复协程继续上次中断的地方继续执行

通过static变量和数据结构保存协程数据

协程让出执行后,等待特定的帧数,时间,和其它协程完成

开始看代码:

typedef enum
{
/**
* Coroutine wait for frame count to waitValue
*/
coroutine_wait_frame,
 
/**
* Coroutine wait for second count to waitValue
*/
coroutine_wait_second,
 
/**
* Coroutine wait for other Coroutine to finish
*/
coroutine_wait_coroutine,
 
/**
* Coroutine just run forward
*/
coroutine_wait_none,
}
CoroutineWaitType;

先定义协程让出执行后,等待的类型。可以看到这里定义了几种类型,可以等待帧数,时间,其它协程。

typedef enum
{
/**
* Coroutine enter queue ready to running
*/
coroutine_state_ready,
 
/**
* Coroutine has started to execute
*/
coroutine_state_running,
 
/**
* Coroutine already finished and waiting for reuse
*/
coroutine_state_finish,
}
CoroutineState;

这里定义协程的状态。等待执行,正在执行包括中断的也算在执行的,还有执行完成的。我们后面会介绍,有一个协程管理器。所有的协程进入管理器,被轮询检测。完成后的协程会被缓存起来,下次请求协程的时候会先检查缓存的协程可否使用。

typedef struct Coroutine Coroutine;
typedef void (*CoroutineRun)(Coroutine* coroutine);
 
struct Coroutine
{
/**
* Record coroutine run step
*/
int step;
 
/**
* Coroutine implement function
*/
CoroutineRun Run;
 
/**
* Coroutine current state
*/
CoroutineState state;
 
/**
* Coroutine wait value to execute
*/
float waitValue;
 
/**
* Record wait progress
*/
float curWaitValue;
 
/**
* Coroutine wait types
*/
CoroutineWaitType waitType;
 
/**
* Hold params for CoroutineRun to get
* when coroutine finish clear but the param create memory control yourself
*/
ArrayList(void*) params[1];
 
/**
* Hold Coroutines wait for this Coroutine to finish
*/
ArrayList(Coroutine*) waits [1];
};

这里定以了一个协程的数据结构。CoroutineRun 就是一个C语言的函数,真正执行的协程函数。

step 用来保存CoroutineRun执行到哪一行了。下次继续这一行执行。后面会介绍,使用宏定义 __LINE__来捕获函数执行的函数,保存到step。

Run 就是执行的函数指针。

state 用来标示协程处在什么状态。

waitValue 表示协程等待的数值,帧数还是时间。

curWaitValue 就是当前等待了多少数值,这个值抵达waitValue表示协程等待结束了。

waitType 表示等待的类型。是等待帧数,还是时间,还是其它协程完成。

params 是绑定的一个动态数组,存放需要在协程函数里使用的参数。ArrayList是自定义类型,可以替换为其它相同实现。后面的()仅仅是一个空参数的宏定义。

waits 也是一个动态数组,存放的是等待当前协程的其它协程。也就是说有多个协程在等待这个协程,当这个协程完成的时候会释放等待队列的其它协程。这里并没有使用一个指针保存等待的协程,而是选择了保存等待自己的协程数组。因为协程使用了缓存系统,一个协程结束,就要进入缓存队列,依赖它的协程需要立马得到通知。

接下来,我们提供一组宏定义,用在 CoroutineRun 中,来完成协程的功能。

#define ACoroutineAddParam(coroutine, value) \
AArrayListAdd(coroutine->params, value)
 
/**
* return value
*/
#define ACoroutineGetParam(coroutine, index, type) \
AArrayListGet(coroutine->params, index, type)
 
/**
* return valuePtr
*/
#define ACoroutineGetPtrParam(coroutine, index, type) \
AArrayListGetPtr(coroutine->params, index, type)

这是在协程对象上绑定和获取数据,为了在协程函数内使用外部数据。就是使用协程对象的params数组。

#define ACoroutineBegin() \
switch (coroutine->step) \
{ \
case 0: \
coroutine->state = coroutine_state_running
 
#define ACoroutineEnd() \
} \
coroutine->state = coroutine_state_finish \

这两个宏是协程主体功能的开始和结束。在这两段之内的代码,可以通过后面提供的宏进行中断。这里是建立了一个switch case代码段,协程的代码处在这个代码段中,就可以利用case任意跳转。每次跳转的位置由step标识。

#define ACoroutineYieldFrame(waitFrameCount) \
coroutine->waitValue = waitFrameCount; \
coroutine->curWaitValue = 0.0f; \
coroutine->waitType = coroutine_wait_frame; \
coroutine->step = __LINE__; \
return; \
case __LINE__: \
 
#define ACoroutineYieldSecond(waitSecond) \
coroutine->waitValue = waitSecond; \
coroutine->curWaitValue = 0.0f; \
coroutine->waitType = coroutine_wait_second; \
coroutine->step = __LINE__; \
return; \
case __LINE__: \
 
#define ACoroutineYieldCoroutine(waitCoroutine) \
coroutine->waitValue = 0.0f; \
coroutine->curWaitValue = 0.0f; \
coroutine->waitType = coroutine_wait_coroutine; \
AArrayListAdd((waitCoroutine)->waits, coroutine); \
coroutine->step = __LINE__; \
return; \
case __LINE__: \

这里提供了,在begin和end之间中断的功能,等待帧数,等待时间,等待其它协程。原理是,使用这几个宏的时候,会用__LINE__赋值step,这样step就持有了当前行数变量。先return结束函数,在添加了case __LINE__,这样下次再次执行这个函数的时候,就会直接跳到上次return后的一个case上,继续执行。保存状态的变量需要使用static local变量保存,或是利用params传入。

#define ACoroutineYieldBreak() \
coroutine->state = coroutine_state_finish; \
return \

中断协程就是设置状态直接跳出。由于在begin和end中可能嵌套有循环,所以不能break,要直接return。
那看看怎么使用:

static void CRun(Coroutine* coroutine)
{
ACoroutineBegin();
ALogD("### begin");
 
ACoroutineYieldSecond(5.0f);
ALogD("### yield second 5");
 
ACoroutineYieldSecond(10.0f);
ALogD("### yield second 10");
 
ACoroutineYieldFrame(100.0f);
ALogD("### yield frame 100");
 
ACoroutineEnd();
}
 
void main()
{
ACoroutine->StartCoroutine(CRun);
}

只要在begin和end之间,使用Yield就可以让出执行流程,然后在返回接着执行。再次强调,需要保存进度的变量,需要使用params保存或是static local变量。那么,让出执行流程,是如何恢复的呢。那是因为所有协程都在一个协程管理器。协程管理器每帧都会执行控制协程的流程。代码如下。

struct ACoroutine
{
/**
* Bind CoroutineRun with Coroutine and enter queue ready to run
*/
Coroutine* (*StartCoroutine)(CoroutineRun Run);
 
/**
* Update on every frame
*/
void (*Update) (float deltaTime);
};

extern struct ACoroutine ACoroutine[1];

协程管理器,需要一个CoroutineRun函数就可以启动,然后在 CoroutineRun 中使用协程的功能。协程管理器的完整实现如下。


static ArrayIntMap(Coroutine*) coroutineMap [1] = AArrayIntMapInit(Coroutine*, 20);
static ArrayList (Coroutine*) coroutineList[1] = AArrayListInit (Coroutine*, 20);
 
static Coroutine* StartCoroutine(CoroutineRun Run)
{
    Coroutine* coroutine = AArrayListPop(coroutineList, Coroutine*);
 
    if (coroutine == NULL) {
        coroutine = (Coroutine*) malloc(sizeof(Coroutine));
 
        AArrayList->Init(sizeof(void*), coroutine->params);
        coroutine->params->increase = 4;
        AArrayList->Init(sizeof(Coroutine*), coroutine->waits);
        coroutine->waits->increase = 4;
    } else {
        AArrayList->Clear(coroutine->params);
        AArrayList->Clear(coroutine->waits);
    }
 
    coroutine->Run = Run;
    coroutine->step = 0;
    coroutine->waitValue = 0.0f;
    coroutine->curWaitValue = 0.0f;
    coroutine->waitType = coroutine_wait_none;
    coroutine->state = coroutine_state_ready;
 
    AArrayIntMapPut(coroutineMap, coroutine, coroutine);
 
    return coroutine;
}
 
static void Update(float deltaTime)
{
for (int i = coroutineMap->arrayList->size - 1; i > -1; i--)
{
Coroutine* coroutine = AArrayIntMapGetAt(coroutineMap, i, Coroutine*);
 
if (coroutine->waitType == coroutine_wait_coroutine)
{
continue;
}
else if (coroutine->curWaitValue >= coroutine->waitValue)
{
coroutine->Run(coroutine);
 
if (coroutine->state == coroutine_state_finish)
{
AArrayIntMap->RemoveAt(coroutineMap, i);
 
// add to cache
AArrayListAdd(coroutineList, coroutine);
 
// set waiting coroutines execute forward
for (int j = 0; j < coroutine->waits->size; j++)
{
Coroutine* wait = AArrayListGet(coroutine->waits, j, Coroutine*);
 
ALogA
(
wait->state != coroutine_state_finish,
"Coroutine [%p] can not finish before wait coroutine [%p] finish",
wait, coroutine
);
 
wait->waitType = coroutine_wait_none;
}
 
continue;
}
}
else
{
switch (coroutine->waitType)
{
case coroutine_wait_frame:
coroutine->curWaitValue += 1.0f;
break;
 
case coroutine_wait_second:
coroutine->curWaitValue += deltaTime;
break;
}
}
}
}
 
struct ACoroutine ACoroutine[1] =
{
    StartCoroutine,
    Update,
};

代码量很少,Update函数需要每帧都调用。ArrayIntMap 和ArrayList 就是自定义的字典映射和动态数组。我在开发游戏中使用过lua和unity的C#中的协程。这套实现也是模拟了unity里面协程的接口。最后,说一下个人理解的协程的好处。

协程,能够把一个计算或是操作,分解成若干步,并且可以再任何一步停下来,并在需要的时候继续执行剩下的步骤。

这样的模型,给予了更细粒度的控制一个操作或是功能。

比如,一个非常耗时间的操作,被分步执行可以更好的控制程序响应。

比如,一个操作需要依赖各种条件,可以更好的处理条件不满足时候的情况。

也能够更好的把操作或是计算过程中的状态变化,与其它的状态变化交互。而然,程序运行的过程就是抽象数据和结构不断变化的过程,协程能够优雅自然的进行这个变化过程的需求。

WebAssembly

通过Web执行一种类似于机器码的程序,简称wasm。相对于JS解析执行性能大大提升,目前主流浏览器都已支持。
wasm 本身是一种字节码标准,一般通过c/c++、GO、Rust来进行开发,并编译成wasm。其中Rust在这块相对更活跃一些
场景:如图像处理、视觉效果、3D游戏、其他需要CPU高性能计算的场景

编译出第一个wasm模块

安装 wasm-pack,它是 Rust-Wasm 官方工作组开发,用于构建 wasm 应用程序的工具。

cargo install wasm-pack

创建项目,因为编写的是 wasm 所以我们选择 lib 。

cargo new --lib  mywasm

修改 Cargo.toml 增加依赖项。

[package]
name = "mywasm"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

修改 lib.rs

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn echo() -> String {
    format!("{}", "maksim")
}

如果我们想要对外暴露函数可以在函数上方加上 #[wasm_bindgen] 注解,在上面的额代码中,我们暴露了一个 echo 函数,用来打印输出一段字符。

接下来,我们在命令行中执行:

$ wasm-pack build -t nodejs

$ ls pkg/
mywasm.d.ts         mywasm.js           mywasm_bg.js        mywasm_bg.wasm      mywasm_bg.wasm.d.ts package.json

wasm-pack build 是用来构建代码,-t nodejs 是用于指定生成 nodejs 可引入的 wasm 代码。pkg是我们生成的 wasm 文件。

使用 node 调用我们的 wasm 模块

我们在根目录中建立一个 test.js 的文件。

let {echo} =  require("./pkg/mywasm");

console.log(echo());

执行结果如下:

$  node test.js
maksim

从 js 中导入函数到 Rust

我们如果想要使用js 中的函数只需要将 js 中的函数原型引入到 rust 中即可,wasm_bindgen 中提了该功能。

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    #[wasm_bindgen(js_namespace = console)]
    fn log(str: &str);
}

#[wasm_bindgen]
pub fn echo()  {
    log("hello maksim")
}

执行结果如下:

hello maksim
undefined

支持参数打印

现在我们对 echo 增加支持传递参数。

#[wasm_bindgen]
pub fn echo(s: &str)  {
    log(s)
}

修改test.js 的调用。

let {echo} =  require("./pkg/mywasm");

console.log(echo('abc'));

// 运行结果
//abc
//undefined

用宏来简化 echo

针对于这类的输出函数,其实我们使用宏要更方便一些。

macro_rules! echo {
    ($expr:expr) => {
        log(format!("{}", $expr).as_str());
    };
}

#[wasm_bindgen]
pub fn echo(s: &str)  {
    echo!(s)
}

在 JS 中使用 Rust 的结构体

我们在 lib.rs 下增加一个 UserModel 的结构体,并且增加两个函数。

#[wasm_bindgen]
pub struct UserModel {
    user_id: i32,
}

#[wasm_bindgen]
impl UserModel {
    pub  fn get_user_id(&self) -> i32 { 
        self.user_id 
    }
}

#[wasm_bindgen]
pub fn new_user(id: i32) -> UserModel {
    UserModel { user_id: id }
}
  • get_user_id 用来返回 user_id。
  • new_user 用来创建 UserModel 结构体

修改 test.js

let {echo, new_user} =  require("./pkg/mywasm");

let user = new_user(12);
echo(user.get_user_id().toString());

由于目前 echo 仅支持字符串,我们需要对 get_user_id() 的返回值进行类型转换。

运行结果如下:

$ node test.js             
12

接下来,我们对这段代码进行优化,因为我们的代码不可能只写在一个文件里面。我们在 src 目录下创建一个名叫 models 的文件夹,用来存放我们的 model 代码。

首先是创建 mod.rs 这是 Rust 的基础,用来导出模块。

pub mod user_model;

然后创建 user_model.rs。

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct UserModel {
    user_id: i32,
}

#[wasm_bindgen]
impl UserModel {
    pub  fn get_user_id(&self) -> i32 { 
        self.user_id 
    }

    #[wasm_bindgen(constructor)]
    pub fn new() -> UserModel {
        UserModel { user_id: -1 }
    }
}

#[wasm_bindgen]
pub fn new_user(id: i32) -> UserModel {
    UserModel { user_id: id }
}

一定要注意最上面的导入,由于已经拆分成独立的文件了,如果没有导入会报错,同时我们在代码中增加了下面这段代码

#[wasm_bindgen(constructor)]
pub fn new() -> UserModel {
    UserModel { user_id: -1 }
}

对应到 js 中就是构造方法。

let {echo, UserModel} =  require("./pkg/mywasm");

let user = new UserModel();
echo(user.get_user_id().toString());

执行代码会输出-1。

接下来我们增加 getter 方法。

#[wasm_bindgen(getter)]
pub fn uid(&self) -> i32 { 
        self.user_id 
}

#[wasm_bindgen(setter)]
pub fn set_uid(&mut self, value: i32) { 
        self.user_id = value 
}

其中 setter 的命名是固定写法,必须以 set_ 开头,代码中还需要注意 &mut self。

在 Go 中的错误处理与其他语言例如 Java,PHP 的错误处理还是有很大差异的,他没有异常的 try catch 机制,而是在函数的返回值中带上 error,让调用者先判断 error 在处理函数的返回值,调用者通过 error 的级别来判断是否触发 painc,这种高度自主化的错误处理模式原本是 go 语言的一大特色,但是在实际开发中代码会出现一堆的错误判断逻辑。

在发生错误的时候也缺少错误的堆栈信息,从而导致问题排查的效率不高,在这一小节,我们来学习如何优雅的处理 Go 的错误。

Error 的本质

Go Error 就是普通的一个接口,普通的值。

type error interface {
    Error() string    
}

我们经常使用 errors.New() 来返回一个 errorString 的指针。

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

在基础库中大量的自定义 error。

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")
)

我们将它们成为哨兵 error,是一个自己定义好的包级别的对象,外面的人只要加载了这个包,就直接可以用到这个对象,可以进行==等一系列运算。

我们可以看到,在里面有一个小细节,哨兵error 中,会把所在包打在里面,这样可以明确的知道,我是哪个包出现了错误,在我们写业务代码时,最好也加上这样的处理。

Error 为什么要返回指针

我们来看一下下面的代码:

package main

import (
    "errors"
    "fmt"
)

type errorString string

func (e errorString) Error() string  {
    return string(e)
}

func New(text string) error  {
    return errorString(text)
}

var ErrNamedType = New("EOF")
var ErrStructType = errors.New("EOF")

func main()  {
    if ErrNamedType == New("EOF"){
        fmt.Println("Named Type Error")
    }

    if ErrStructType == errors.New("EOF") {
        fmt.Println("Struct Type Error")
    }
}

执行上面的代码:

Named Type Error

自定义的 error 在进行等值运算的,明明不是一个error,但是却返回了Named Type Error,这是因为在底层使用的是 string 类型,底层两个string ,由于没有去取地址,这样一来,进行的就是等值返回,对于标准库的 error 来说是不返回的,这也是为什么标准库用结构体包一下,并且返回一大个地址。会比较两个指针地址是否是一个。

这样就可以避免一些奇怪的Bug,两个人定义了两个错误,但是文本内容一模一样,结果相等了。

如果一样用结构体包裹一下,但是使用取地址符,那么在进行等值运算的时候,就会首先按照结构体里面的字段是否一样进行判断。

预定义的特定错误

预定义的特定错误,我们叫为 sentinel error,这个名字来源于计算机编程中使用一个特定值来表示不可能进行进一步处理的做法。所以对于 Go,我们使用特定的值来表示错误。

if err == ErrSomething { … }

类似的 io.EOF,更底层的 syscall.ENOENT。

使用 sentinel 值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较。当您想要提供更多的上下文时,这就出现了一个问题,因为返回一个不同的错误将破坏相等性检查。

甚至是一些有意义的 fmt.Errorf 携带一些上下文,也会破坏调用者的 == ,调用者将被迫查看 error.Error() 方法的输出,以查看它是否与特定的字符串匹配。

不应该依赖检测 error.Error 的输出,Error 方法存在于 error 接口主要用于方便程序员使用,但不是程序(编写测试可能会依赖这个返回)。这个输出的字符串用于记录日志、输出到 stdout 等。

Sentinel errors 成为你 API 公共部分。

  • 如果您的公共函数或方法返回一个特定值的错误,那么该值必须是公共的,当然要有文档记录,这会增加 API 的表面积。
  • 如果 API 定义了一个返回特定错误的 interface,则该接口的所有实现都将被限制为仅返回该错误,即使它们可以提供更具描述性的错误。

比如 io.Reader。像 io.Copy 这类函数需要 reader 的实现者比如返回 io.EOF 来告诉调用者没有更多数据了,但这又不是错误。

Sentinel errors 在两个包之间创建了依赖。

sentinel errors 最糟糕的问题是它们在两个包之间创建了源代码依赖关系。例如,检查错误是否等于 io.EOF,您的代码必须导入 io 包。这个特定的例子听起来并不那么糟糕,因为它非常常见,但是想象一下,当项目中的许多包导出错误值时,存在耦合,项目中的其他包必须导入这些错误值才能检查特定的错误条件(in the form of an import loop)。

我的建议是避免在编写的代码中使用 sentinel errors。在标准库中有一些使用它们的情况,但这不是一个您应该模仿的模式。

Error types

我们可以将错误信息结构化,自己来实现一个 error。在 go 源码包中就有对 error 的扩展,与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。

//src : /src/os/error.gop 
type PathError struct {
  Op string
  Path string
  Err error
}

func (e *PathError) Error() string {
  return e.Op + " " + e.Path + " " + e.Err.error()
}

虽然提供了更多的错误信息,但是却造成了新的问题,调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

结论是尽量避免使用 error types,虽然错误类型比 sentinel errors 更好,因为它们可以捕获关于出错的更多上下文,但是 error types 共享 error values 许多相同的问题。

因此,我的建议是避免错误类型,或者至少避免将它们作为公共 API 的一部分。

如果你尝试学习 Go 或者你正在为自己建立一个 PoC 或一个玩具项目,这个项目布局是没啥必要的。从一些非常简单的事情开始(一个 main.go 文件绰绰有余)。当有更多的人参与这个项目时,你将需要更多的结构,包括需要一个 toolkit 来方便生成项目的模板,尽可能大家统一的工程目录布局。

因为在大型多人协作的项目中如果没有统一的项目目录结构,随着项目不断的迭代,项目工程目录将变得凌乱无序,不易扩展,不可维护。合理设计目录结构的最终目的是为了提高项目的整洁可读,可扩展性和可交流性。

一个大家可以很好理解的架构本身就是开发人员的一种交流语言,本节将为大家介绍 Go 项目的标准目录布局,了解这些目录一方面是为了给自己设计工程目录结构的时候提供一个标准的参考,另外一方面在我们阅读 Go 项目源码的时候也可以很快了解想这些源码目录具体存放的是哪一方面的源代码。

同时 Go 语言特性支持的目录会重点进行讲解,以及在 GO 工程中那些目录是不推荐的。

一个基本的 Go 工程会有三个目录 cmd, internal, pkg基础目录来分层,这并不是 Go 官方团队定义的标准,但这确实是目前 Go 生态中比较常见的布局形式,目前被普遍使用的目录结构是:github.com/golang-standards/project-layout,我们可以在 github 上看到它的目录结构。

我们将代码克隆到本地后使用 tree 命令进行查看目录结构。

➜  WorkSpace tree go-project-layout
go-project-layout
├── LICENSE.md
├── Makefile
├── README.md
├── README_es.md
├── README_fr.md
├── README_ja.md
├── README_ko.md
├── README_ptBR.md
├── README_ro.md
├── README_ru.md
├── README_tr.md
├── README_zh-CN.md
├── README_zh-TW.md
├── README_zh.md
├── api
│   └── README.md
├── assets
│   └── README.md
├── build
│   ├── README.md
│   ├── ci
│   └── package
├── cmd
│   ├── README.md
│   └── _your_app_
├── configs
│   └── README.md
├── deployments
│   └── README.md
├── docs
│   └── README.md
├── examples
│   └── README.md
├── githooks
│   └── README.md
├── go.mod
├── init
│   └── README.md
├── internal
│   ├── README.md
│   ├── app
│   │   └── _your_app_
│   └── pkg
│       └── _your_private_lib_
├── pkg
│   ├── README.md
│   └── _your_public_lib_
├── scripts
│   └── README.md
├── test
│   └── README.md
├── third_party
│   └── README.md
├── tools
│   └── README.md
├── vendor
│   └── README.md
├── web
│   ├── README.md
│   ├── app
│   ├── static
│   └── template
└── website
    └── README.md

一个 Go 项目主要包含前后端代码,构建后部署的工具,测试和文档这几部分,为了方便大家理解,可以将他们进行一下归类。

  • 配置文件: 配置文件我们一般都会将其放在 configs 目录下
  • 前端代码 :/web、/website、/assets

    • web 目录存放前端代码,服务端模板和单页应用
    • website 下存放 github页面或项目站点相关数据
    • assets 存放项目中用的其他资源(Image,CSS,JavaScript等)
  • 后端代码:/internal,/cmd,/vendor,/third_party,/pkg,/api

    • internal,主要是用来分离应用中共享和非共享的内部代码(go 1.4 开始编译的时候会进行强行校验)

      • 限制公开程序实体只能被其父目录下面的包或者子包引用,适合多个服务共存的情况下隔离各个服务。

        • 从运营管理服务相关代码和用户层代码隔离避免误调用。
        • 目录辨识度高,有效杜绝使用者随意导入
    • cmd:程序入口代码,不含业务逻辑,有两个关键性的原则

        1. 不包含业务代码,不能被其他的包导入,负责程序的启动关闭以及配置的初始化等;
        2. cmd 下面的子目录名跟你期望生成的可执行程序的文件名应该是一致的。
    • vendor:包含了工程中所有依赖的第三方源代码,是 go 中最早依赖包的管理方式,目前大多数项目是使用 go mod 的方式来进行管理,如果需要即使用以前的依赖,还要使用 go mod 我们可以通过 go mod vendor 命令来创建 vendor 目录,在 go 1.14 版本以后我们只需要使用 go vendor 命令就可以了。
    • third_party: 其中存放的也是第三方包,不过这些第三方包是我们自己进行了一定的修改,跟 vendor 进行区分,方便更新
    • pkg:与 internal 相对,外部项目可以直接导入,在实际工作中,为了避免重复造轮子,我们会沉淀整个企业使用的公共包作为独立的仓库提供给各个应用程序使用。
    • api:主要用来存储接口定义文件比如 proto 的一些定义文件。
  • 项目工具,构建和部署相关:Makefile,/scripts/, /tools,/build, /deployments, /init

    • Makefile:makefile提供了一些指令,方便我们编译我们的应用程序,通常工程的根目录下都会有这样的文件;
    • scripts:脚本文件,完成构建,安装,分析检查等功能,Makefile 文件中各个指令的具体实现。
    • tools:项目的一些脚本工具,比如根据项目模型生成相关的代码,可以调用 pkg 和 internal 下面的代码。
    • build: 主要存放安装包和持续集成相关文件,比如说 Dockerfile。
    • deployments:存放系统和容器编排部署配置和模板,比如docker-compose。
    • init:应用程序初始化的脚本,比如systemd 和进程管理(比如supervisord)配置
  • 外部测试代码和数据:/test,这里面用于存放外部包的测试代码和数据,比如经过魔改的第三方库,而应用里面的单元测试还是放在你应用相关的 go 文件下面。
  • 文档:/docs,/examples, README.md,CHANGELOG

    • docs:开发文档,用户手册,安装指南,设计文档等
    • examples:存放工程内部和工程中可被外部导入的包的示例代码,方便使用者快速入门,企业内部公共包可以提供使用这些包构建一些内部项目地址
    • README.md:一般用来介绍项目代码,功能和安装指南,文档地址等。
    • CHANGELOG:记录每一次发版的变更内容
  • 不建议的目录:/src,/model,/common,/util

    • src: 在 GOPATH 模式下,代码会被放到 $GOPATH下面的 src 目录下,在导入路径中就会包含两个 src 目录,这样看上去不是很规范。
    • model:不建议将实体或类型定义放到 model 目录里,特别是 MVC中 M 层的表结构定义,在 DDD 的设计思想下我们推荐按照业务领域来划分。
    • utils 和 common :无法看出包的具体功能, 久而久之随着代码的迭代容易变成大杂烩。