GopherChina 2019 keynote 点评

今年的 GopherChina 大会如期而至,没能亲临现场,但是 keynote 绝不会错过。一如往常,谢大第一时间放出了今年的keynote。今年的 keynote 中有不少老面孔,不知道以后大会是否会把固定若干老面孔作为惯例。如果你错过了去年的 keynote, 可以参见鄙人拙文《GopherChina 2018 keynote 点评》

整体上,今年的演讲主题跟往年所涉及的领域和覆盖的范围区别不大,无论你是关注架构、微服务、语言细节,还是数据库、存储、业务及应用系统构建,都能从中找到自己感兴趣的内容。

1.1 大型微服务框架设计实践 – 杜欢

如果你曾经想用比较hack的方式获取goroutine id, 那么你有很大可能性使用过杜欢的goroutine. 也因为写Golang 获取 goroutine id 完全指南的缘故,跟杜欢结识。看到这个keynote,心里还是有种从未谋面,但是久违的熟悉感。在大概3年前,我其实也做过类似的框架设计和开发。很多理念和原则的确是 cant't agree more. 其中,“框架和业务正交”的原则也是充分发挥了golang自带的正交特性。

在框架中,隔离层的思想很朴素,但是很实用。我曾经因为在设计之初没有引入隔离层,自己手动修改了多个数据库驱动库,以满足框架某个特性的引入。如今想想,真的是血与泪的教训。

1.2 用Go打造Grab的路径规划和ETA引擎

不得不感慨,Grab 的业务才是真的大型生活类服务。从形态上看,已经约等于国内滴滴+美团+顺丰组合了。演讲内容偏向算法。对地图路径规划(无论是游戏地图还是现实地图)感兴趣的同学,可以看看算法到实际工程落地之间的gap如何弥补和解决。

1.3 Go practices in TiDB – 姚维

印象中,PingCAP 出来的speaker分享质量一直都挺高。姚维老师的这次分享也保持了PingCAP一如既往的高水准,深入浅出,以小见大。一直比较好奇TiDB这种对软件质量要求极高且分布式的领域是如何做测试的,看了其Schrodingergofail 的介绍,无论从主观体感还是技术信赖都TiDB加分不少。failpoint 在实现层面是基于golang AST 做的,编译时被转换为一个 IF 语句,整体设计简单直接有效,是我喜欢的风格。

另外一个比较有意思的点是使用 chunk 来优化内存使用。以前只知道使用整块连续的内存分配策略比碎片化的内存分配更有效率,但是不知道连续内存带来的矢量化执行优势。如果你是做高性能数据库的,这个点一定不能不知道。

1.4 Testing; how, what, why – Dave

Golang官方人员 Dave 大胡子老师出品,必属精品。关于golang如何做测试的资料,看这一个就够了。

1.5 Go 业务开发中 Error & Context – 毛剑

在 golang 1.x 中,错误处理一直是一个不太舒服点。因此才有去年 Rethinking Errors for Go 2 对golang 2.x 错误处理的预览和展望。但是,golang 2 是没有具体时间表的,当前阶段,如果你在实际业务系统中对错误处理有疑惑,可以看看毛剑的处理方式。

Context 其实算是一个老生常谈的话题了,但是毛剑总结了很多实际使用中的最佳实践,分享内容还是诚意满满的。

1.6 Go并发编程实践 – 晁岳攀

从源码级别探究Go在并发层面的基础库实现。跟去年的深入CGO编程一个风格,内容非常全面和丰富,有细节有深度。如果想深入golang源码,一定不可以错过。

1.7 百度APP Go 语言实践 – 陈肖楠

从ppt内容看,算是一个大厂在小场景的golang实践。涉及的问题,以鄙人浅见:使用golang落地1年以内的创业公司都会遇到。给出的解决方案和踩过的坑已经远看不到国内巨头的风范了。如果百度再被扣上技术不行的标签,那就是哪都不行了……

1.8 Golang to build a real-time interactive SaaS Cloud – 董海冰

golang 在 WebRTC 场景下的工程实践。以前对 WebRTC 比较模糊,细致看了分享内容以后,才发现这块的内容和涉及的技术如此广博。前端时间,提供视频会议解决方案的 zoom 上市了,日后我们应该有很大概率看到更多 golang 和 WebRTC 的落地方案。

2.1 基于MINIO的对象存储方案在探探的实践 – 于乐

作者用 golang 撸了一个支持多集群的分布式对象存储系统。有两个技术细节值得技术投资和持续关注:

  1. Reed-Solomon,一种低冗余,高可靠的纠删码。golang 版本的实现可以参见reedsolomon.
  2. The Linux Storage Stack Diagram. 能让你系统全面的了解 IO,并且知道 Direct IO, page cache 的本质。

2.2 从零开始用 Go 实现 Lexer & Parser – 何源

作者编译原理的底子还是在的。想当年,我们该课程的期末课程设计就是编写一个编译器。不过大部分时候,如作者所言,如果不是万不得已,不要自己写 parser. 毕竟,在不使用正则表达式的前提下,golang 提供了非常完善易用的 AST 基础库支持。

2.3 高性能高可用的微服务框架TarsGo的腾讯实践 – 陈明杰

golang和微服务经过这几年的演进发展,无论是基础框架还是周边生态,已经达到了水乳交融的程度。鹅厂的这个实践从当前时间点看,没有什么亮点,更没有什么突破。本以为会有一些 service mesh 方面的尝试,但是比较遗憾,这方面从分享内容看还走得比较靠后。

2.4 闪电网络—BTC小额支付解决方案 – 方圆

不知道这个方圆老师跟去年代表罗辑思维做分享的speaker是不是同一个人?如果是的话,真的是选错了行业风口呀。币圈有风险,跳巢需谨慎。

2.6 用Go构建高性能数据库中间件- 徐成选

一个使用golang打造中间件的实践。文末提到了一些优化方案和细节,挺受用。

2.7 花椒直播基于golang的中台技术实践 – 周洋

周洋老师也是老面孔了,第一次出现在gopher大会应该是大表360做IM长连接的分享。听那一次分享自己几乎是跪着听完的,因为在那之前自己要解决的问题和场景跟其非常类似,只是碍于当时的人手和自己的技术栈储备,我没能做出周洋那样的方案和架构,而是用了一个比较trick的方案。晃眼间,4年过去了,周洋对于中台的思考又给了自己很多启发。感谢 GopherChina 这样的平台,感谢周洋老师的分享。

2.8 知乎社区核心业务 Golang 化实践 – 杜旭

作者分享了知乎从 python 迁移到 go 的历程。巧合的是,三年前,我们也做了同样的事情,同样是从 python 迁移到 go. 不过作者有几点做得比当时的我们更好:

  1. 在接口验证环节上,我们当时希望靠尽可能覆盖全面的单元测试和QA验证来保证;知乎在额外还引入了python和go版本的接口交叉校验。test case的丰富和覆盖程度应该比我们当年更好。
  2. 引入了静态代码检查。如果用强类型语言不适用静态代码检查,那么就损失了强类型语言一般的优势。道理都知道,但是碍于当时CI/CD流程不够完善,我们这个环节一直是缺失的。

注意

以上内容只是看完keynote以后的个人观感。因为没有去现场,细节肯定有所缺失,有些观点也未必跟现场同学的反馈吻合。希望后面放出大会现场视频以后,自己能够进一步完善以上内容。

Go 中如何准确地判断和识别各种网络错误

Go 自带的网络标准库可能让很多第一次使用它的人感慨,这个库让网络编程的门槛低到了令人发指的地步。然而,封装层次与开发人员的可控性往往是矛盾的。Go 的网络库封装程度算是一个不错的折衷,绝大部分时候,我们只需要调用 Dial, Read, Write Close 几个基本操作就可以了。

但是,网络是复杂的。我们有时候需要细致的处理网络中的各种错误,根据不同的错误进行不同的处理。比如我们遇到一个网络错误时,需要区分这个错误是因为无法解析 host ip, 还是 TCP 无法建立连接,亦或是读写超时。一开始的时候,我们的写法可能是这样的:

    errString := err.Error()
    fmt.Println(errString)
    switch {
    case strings.Contains(errString, "timeout"):
        fmt.Println("Timeout")
    case strings.Contains(errString, "no such host"):
        fmt.Println("Unknown host")
    case strings.Contains(errString, "connection refused"):
        fmt.Println("Connection refused")
    default:
        fmt.Printf("Unknown error:%s", errString)
    }

这种根据错误信息进行字符串匹配进行判断的方法有非常明显的局限性:该错误信息依赖于操作系统,不同的操作系统对于同一错误返回的字符串信息可能是不同的。因此,这种判断网络错误类型的方法是不可靠的。那么有没有一种准确而可靠的判断各种网络错误的方式呢?答案是肯定的。

我们知道在 Go 中,error 是一个内建的 interface 类型:

type error interface {
        Error() string
}

要准确判断不同的错误类型,我们只需要类型断言出其错误类型即可。

在 Go 的网络标准库中,错误类型被统一封装为 net.Errorinterface 类型:

type Error interface {
        error
        Timeout() bool   // Is the error a timeout?
        Temporary() bool // Is the error temporary?
}

net.Error 类型的具体 concrete 类型又被封装为 net.OpError 类型:

type OpError struct {
        // Op is the operation which caused the error, such as
        // "dial", "read" or "write".
        Op string

        // Net is the network type on which this error occurred,
        // such as "tcp" or "udp6".
        Net string

        // For operations involving a remote network connection, like
        // Dial, Read, or Write, Source is the corresponding local
        // network address.
        Source Addr

        // Addr is the network address for which this error occurred.
        // For local operations, like Listen or SetDeadline, Addr is
        // the address of the local endpoint being manipulated.
        // For operations involving a remote network connection, like
        // Dial, Read, or Write, Addr is the remote address of that
        // connection.
        Addr Addr

        // Err is the error that occurred during the operation.
        Err error
}

其中,net.OpError.Err 可能是以下几种类型:

*os.SyscallError 错误比较特殊,与具体操作系统调用有关:

type SyscallError struct {
        Syscall string
        Err     error
}

对于我们关心的网络错误,SyscallError.Err 一般为 sys.Errno 类型,与网络错误相关的常用值有:

  • syscall.ECONNREFUSED
  • syscall.ETIMEDOUT

看到这里,你可能忍不住要吐槽 Go 这种错误嵌套处理了,事实上,官方也意识到了这种错误处理的问题,在 Go 2中,可能会出现新的错误和异常处理方式,可以参见 GopherChina 2018 keynote 点评: RETHINKING ERRORS FOR GO 2.

当前阶段,我们依然要直面这种错误处理方式。为了方便大家理解 Go 网络标准库中处理错误的方式,我们把上面的错误嵌套整理了一张关系图:

明白了网络标准库中处理错误的逻辑,判断和识别各种类型的网络错误就非常简单了:对网络错误进行类型断言。以我们团队主要关心的 DNS 解析错误、TCP 无法建立连接、读写超时为例,判断逻辑可以是这样:

func isCaredNetError(err error) bool {
    netErr, ok := err.(net.Error)
    if !ok {
        return false
    }

    if netErr.Timeout() {
        log.Println("timeout")
        return true
    }

    opErr, ok := netErr.(*net.OpError)
    if !ok {
        return false
    }

    switch t := opErr.Err.(type) {
    case *net.DNSError:
        log.Printf("net.DNSError:%+v", t)
        return true
    case *os.SyscallError:
        log.Printf("os.SyscallError:%+v", t)
        if errno, ok := t.Err.(syscall.Errno); ok {
            switch errno {
            case syscall.ECONNREFUSED:
                log.Println("connect refused")
                return true
            case syscall.ETIMEDOUT:
                log.Println("timeout")
                return true
            }
        }
    }

    return false
}

这种错误判定方式除了能解决最开始提到的可靠性和准确性问题,也具有良好的普适性。即基于 net 的其他标准库,如 net/http 也支持这种错误判断方式。

扩展阅读

微信小程序文件上传二三事

这段时间陆陆续续上了好几个微信小程序,功能上都会用到文件上传功能(头像上传、证件照上传等)。在APP上传文件到云端的正确姿势中,我们介绍了我们认为安全的上传流程:

即将密钥保存在服务器,客户端每次向服务器申请一个一次性的signature,然后使用该signature作为凭证来上传文件。一般情况下,向阿里云OSS上传内容,又拍云作为灾备。

随着大家安全意识的增强,这种上传流程几乎已经成为标准姿势。但是,把这个流程在应用到微信小程序却有很多细节需要调整。这里把踩过的坑记录一下,希望能让有需要的同学少走弯路。

微信小程序无法直接读取文件内容进行上传

在我们第一版的上传流程方案中,我们的cds 签名发放服务只实现了阿里云 PutObject 接口的signature发放. PutObject 上传是直接将需要上传的内容以二进流的方式 PUT 到云储存。

但是,微信小程序提供的文件上传API wx.uploadFile 要求文件通过 filePath 提供:

另一方面,微信小程序的 JS API 当前还比较封闭,无法根据 filePath 读取到文件内容,因此也无法通过 wx.request 直接发起网络请求的方式来实现文件上传。

考虑到 wx.uploadFile 本质上是一个 multipart/form-data 网络请求的封装,因此我们只需要实现一个与之对应的签名发放方式接口。阿里云OSS对应的上传接口是 PostObject, 又拍云对应的是其 FORM API. 以阿里云OSS为例,cds 服务生成signature 代码如下:

func GetDefaultOSSPolicyBase64Str(bucket, key string) string {
    policy := map[string]interface{}{
        "expiration": time.Now().AddDate(3, 0, 0).Format("2006-01-02T15:04:05.999Z"),
        "conditions": []interface{}{
            map[string]string{
                "bucket": bucket,
            },
            []string{"starts-with", "$key", key},
        },
    }
    data, _ := json.Marshal(&policy)
    return base64.StdEncoding.EncodeToString(data)
}

func GetOSSPostSignature(secret string, policyBase64 string) string {
    h := hmac.New(sha1.New, []byte(secret))
    io.WriteString(h, policyBase64)
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

小程序端代码如下:

//使用说明
/**
 * 1、引入该文件:const uploadFile = require('../../common/uploadAliyun.js');
 * 2、调用如下:
 * uploadImg: function () {
        const params = {
            _success: this._success
        }
        uploadFile.chooseImg(params);
    },
    _success: function(imgUrl){
        this.setData({
            cover_url: imgUrl,
        })
    },
*/

const uploadFile = {
    _fail: function(desc) {
        wx.showToast({
            icon: "none",
            title: desc
        })
    },
    _success: function() {},
    chooseImg: function(sendData) {
        //先存储传递过来的回调函数
        this._success = sendData._success;
        var that = this;
        wx.chooseImage({
            count: 1,
            sizeType: ['original', 'compressed'],
            sourceType: ["album", "carmera"],
            success: function (res) {
                that.getSign(res.tempFilePaths[0]);
            },
            fail: function (err) {
                wx.showToast({
                    icon: "none",
                    title: "选择图片失败" + err
                })
            }
        })
    },
    //获取阿里上传图片签名
    getSign: function (path) {
        var that = this;
        wx.request({
            url: 'https://somewhere/v2/cds/apply_upload_signature',
            method: 'POST',
            data: {
                "content_type": "image/jpeg",
                "signature_type": "oss_post",
                "business": "xiaochengxu",
                "file_ext": '.jpeg',
                "count": '1'
            },
            success: function (res) {
                let getData = res.data.data[0];
                that.startUpload(getData, path);
            },
            fail: function (err) {
                that._fail("获取签名失败" + JSON.stringify(err))
            }
        })
    },
    //拿到签名后开始上传
    startUpload: function (getData, path) {
        var that = this;
        this.uploadAliYun({
            filePath: path,
            dir: 'wxImg/',
            access_key_id: getData.oss_ext_param.access_key_id,
            policy_base64: getData.oss_ext_param.policy_base64,
            signature: getData.signature,
            upload_url: getData.upload_url,
            object_key: getData.oss_ext_param.object_key,
            content_url: getData.content_url.origin 
        })
    },
    uploadAliYun: function(params) {
        var that = this;
        // if (!params.filePath || params.filePath.length < 9) {
        if (!params.filePath) {
            wx.showModal({
                title: '图片错误',
                content: '请重试',
                showCancel: false,
            })
            return;
        }
        const aliyunFileKey = params.dir + params.filePath.replace('wxfile://', '');

        const aliyunServerURL = params.upload_url;
        const accessid = params.access_key_id;
        const policyBase64 = params.policy_base64;
        const signature = params.signature;
        wx.uploadFile({
            url: aliyunServerURL,
            filePath: params.filePath,
            name: 'file',
            formData: {
                'key': params.object_key,
                'policy': policyBase64,
                'OSSAccessKeyId': accessid,
                'Signature': signature
            },
            success: function (res) {
                if (res.statusCode != 204) {
                    that._fail("上传图片失败");
                    return;
                }
                that._success(params.content_url);
            },
            fail: function (err) {
                that._fail(JSON.stringify(err));
            },
        })
    }
}


module.exports = uploadFile;

使用阿里云OSS域名上传失败

解决签名问题后,发现使用阿里云OSS提供的上传域名无法上传成功,在微信后台尝试添加合法域名的时候,惊奇的发现阿里云OSS的域名直接被微信小程序封禁了:

显然是两个神仙在打架,作为草民只能见招拆招。解决办法就是在阿里云OSS -> bucket -> 域名管理 绑定用户域名:

此外,由于微信小程序已经升级为uploadFile的链接必须是https, 因此还需要在绑定用户域名后设置 证书托管

他山之石,可以攻玉

既然微信能够封禁用阿里云OSS的上传域名,那么微信也可以封禁你自定义的域名。根据以往经验(对天发誓,我们不是有意为之,我们也是受害者……),微信封禁域名一般都是一锅端,即发现一个子域名存在违规内容,那么整个域名都会被封禁。因此,一方面要从技术角度对上传的内容及时检查是否合规(如黄图扫描),另一方面提前做好域名规划,将业务接口域名与自定义的文件上传域名分开,这样即使上传域名被一锅端了,不至于是业务完全不可用。