我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #Golang

2019 年度小结(技术方面)

Redis 的工程价值

今年一开始先是完善了去年的 任务队列,为了让它真正地被用起来,我将之前由另外一个服务实现的定时任务系统也合并进了这个任务队列,让它有了一些固定的流量,以便我来发现性能上的问题,进一步完善它。

这项工作最后取得了还不错的效果,开始有了用户使用任务队列的功能。我也写了一篇文章来介绍 用 Redis 来实现定时任务,当然这篇文章中介绍的是一种经过极度简化的范式,实际上实际的代码要复杂的多,例如序列化、错误处理、结果查询、限流、统计等额外的特性。

这个项目让我再度增加了对 Redis 的好感,这次我用到了比较大量的 Lua Script 来保证分布式架构下的一致性(Redis 的Lua Script 会被独占地执行)。在将一致性需求限制到单个 Redis 实例可以容纳的范围(Redis 只使用一个线程)并且 Redis 运行相对稳定的情况(故障切换会丢失数据),Redis 为业务层提供了一个非常「够用」的高性能的、有一致性保证的数据同步方案。这并不是说 Redis 提供的方案有多么完美,而是在性能、功能、一致性、可靠性上提供了一个非常好的这种,更具有工程价值。

Golang 的表达能力

我还尝试了为 LeanCloud 写一个 Golang 的 SDK(后来因时间安排的关系暂停掉了,目前还未发布),就像之前我为 DeployBeta 写 ORM 一样,同样遇到了 Golang表达能力不足的问题。问题主要在于 Golang 中并没有能让 Struct 继承方法的机制(数据字段则可以通过内嵌匿名 Struct 的方式来继承)。

所以当用户定义一个「继承」自 ORM 基类的 Struct 时,我们无法向用户定义的 Struct 上添加例如 Save、Set 之类的方法,无法有效地追踪用户对于数据对象的改动。

经过几个版本的改动,我最后选择了一种将所有基本类型(string、int)包装为 Struct 的方案:

type Todo struct {
  orm.ObjectMeta

  Name     orm.String `orm:"name"`
  Priority orm.Number `orm:"priority"`
}

todo := Todo{
  Name:     orm.NewString("test"),
  Priority: orm.NewNumber(1),
}

err = orm.Save(&todo)

todo.Name.Set("test")
todo.Priority.Incr(1)

err = orm.Save(&todo)

fmt.Println(todo.Name.Get(), todo.Priority.Get())

这个方案可以做到不以字符串的形式传递字段名(可以得到编译期的类型检查),可以追踪对每个字段进行的修改(包括 Incr 等运算)。我将 Set 添加到了基本类型的封装类型上,将 Save 作为了一个全局方法,避开了 Golang 对于继承的限制。带来的问题则是用户需要通过我们的封装方法(Get)来访问字段的值;同时今后设计嵌套对象时也需要更大的工作量。

所以并不是如 Golang 的支持者说的那样,更少的特性意味着更简单的设计。当业务逻辑确实复杂,语言表达能力又非常匮乏的情况下,会逼着开发者做出一些不优雅的、不易理解的、反常规的设计,这些代码往往非常容易出错(例如反射、代码生成、强制类型转换等),而本来这些需求(如继承)在其他语言里是可以非常轻易地解决的。

TypeScript 的胜利

之前因为对 CoffeeScript 的喜爱,我的 TypeScript 使用经验非常少,终于今年我也不得不去接受 TypeScript 了。今年我用 TypeScript 开发了两个新的后端项目,也更深入地学习了 TypeScript,经过进一步的了解,我逐渐地发现了 TypeScript 的闪光点,之后我会单独写一篇文章来介绍 TypeScript。

TypeScript 有着一个先进的类型系统,这种先进并非是学术意义上的先进,而是工程意义上的先进。它几乎可以为所有 JavaScript 中常见的范式添加静态约束,得益于强大的类型推导,在大部分情况下并不需要自己添加类型标注,但却能在编译期提前发现错误、配合 Language Server 得到准确的代码补全和类型提示信息,完全没有前面提到的 Golang 中的那种束缚感。

因为 TypeScript 并不打算创造新的范式,而是尽可能将 JavaScript 社区中已有的范式用静态类型的语义描述起来。这样最大程度上地降低了 JavaScript 开发者学习的成本,提高了与标准 JavaScript 代码的互操作性,我认为这也是 TypeScript 能够取得成功的关键。

同时我也不得不接受 Atom 的市场已经几乎完全被 VS Code 取代的现实,切换到了对 TypeScript 支持更好的 VS Code。现在想想 Atom 失败的原因一方面是在 CoffeeScript 已经表现出没落的时候选择了 CoffeeScript;另一方面是希望依靠社区的力量,但又缺乏对社区的引导。例如对于插件的 GUI 改动引导不够导致界面卡顿,对于代码补全、调试等常见需求没能建立统一的标准等等。

Kubernetes 的阴谋

今年其中一个新项目是开发一个数据库调度平台,在 Kubernetes 上运行数据库容器,这和我之前在 DeployBeta 实现的原型非常相似,只不过这次是真的要上线的项目。

在去年和今年对 Kubernetes 的了解过程中我逐渐对 Kubernetes 由粉转黑。我现在认为 Kubernetes 是以 Google 为首的三大云计算巨头的垄断工具,他们开发出了一个如此复杂的系统,并引导其作为行业标准。虽然 Kubernetes 是开源并由社区维护的,但真正能够独立搭建好 Kubernetes 及其插件的公司是极少数,甚至可以说除了三大巨头之外,其他的云计算公司都不能提供稳定可靠的 Kubernetes 集群。最后大家在尝试过自己搭建之后,还是不得不购买三大巨头的 Kubernetes 云,毕竟这是行业标准嘛。

今年看过觉得最好的书是「数据密集型应用系统设计(DDIA)」,它给我的数据库调度平台带来了很多启发。书中介绍了分布式架构对于数据库的挑战,包括数据模型、复制、分区、事务、分布式共识等等,以及各个数据库在面对这些挑战时采取的解决方案,只有理清这些思路,才能在面对复杂的业务的时候采用一种或几种合适的数据库。

我的理解是当数据存在于两台或更多的计算机之上时(原因可能是容量或可用性要求),就可以称作「大数据」了。因为从一台到两台是一个质的变化,而从两台到更多只不过是量的变化。就如书中所说,在单机条件下,所有的称作都是确定的,一个操作要么成功要么失败(可能伴随着程序或系统的崩溃);但在分布式条件下,对于经过网络的操作会引入成功和失败之外的第三种情况 —— 网络延迟,你无法预测一个操作会在下一秒完成还是永远都不会完成。所以分布式系统需要被设计成可以在容忍一定的错误(部分失效)的情况下继续运行。无论是一个分布式数据库还是一个分布式的容器平台,其实都在与这种不确定性的超时进行对抗。

写不下去的业余项目

现在我愈发认识到软件开发不是一个人的单打独斗,之前在做一些业余项目的时候还会有一些幻想,幻想自己能长期维护下去、能吸引到其他的贡献者、能建立起一个社区。但现在想想还是以内容作为主要的输出更有可行性。同时因为我对现在的工作非常满意,在工作中基本完全满足了我对于写代码和团队协作的欲望,所以我可以将业余时间放在其他的输出形式上,在接下来一年中输出更多的文章或视频,这样我的经验和知识会给读者带来更大的价值。

2017 年度小结(技术方面)

从今年年初开始,我就尝试在业余时间和一个朋友开发一个容器平台,更多地是实验一些新的技术,也希望能够通过它将自己的一些小应用管理起来,在基本完成后可能会考虑开源。之所以说是实验是因为我选择了一个我几乎完全不了解的技术栈:主要编程语言是 Golang、只使用 Etcd 作为数据库、基于 Docker Swarm 管理容器。

不得不说 Golang 是一个非常难用的语言,在语言层面,为了所谓的「简单」而没有添加 异常泛型 这两个对于高级编程非常重要的特性;在生态上仍没有统一出一个包管理器,如果只发布编译好的二进制程序倒是没问题,但如果发布源代码的话,缺少统一的包管理会带来很多麻烦,以至于很多开发者选择将 vendor 直接包含在版本控制中。

在这个项目中,没有异常和泛型真的给我带来了很大的困扰,几乎一半的代码都在进行繁琐的错误检查,没有泛型则很难实现一些通用的函数,或者不得不进行强制类型转换。这让我觉得 Golang 的使用场景非常受限:因为有 GC,它难以胜任对实时性要求较高的底层的工作;又因为缺少高层次的抽象手段,不适合业务逻辑复杂的应用编程(例如 Web 后端),可以说不上不下,只适合于一些业务逻辑不复杂的中间件,或者一些客户端命令行工具(毕竟在三个平台下都没有运行时依赖)。

Etcd 是一个我之前没有接触过的数据库类型,它是分布式的键值数据库,可以在大多数节点存活的情况下保证读写的强一致性,也提供了事务、订阅修改、TTL、检索历史快照等功能。我在这个项目中直接使用 Etcd 作为唯一的数据库存储所有数据,也使用 Golang 对 Etcd 的 API 进行了简单的封装,以便更好地使用 JSON 和 Etcd 的事务。

因为毕竟是业余项目,这个项目一直进展缓慢,在今年的最后我还尝试在 Swarm 上实现高可用的有状态容器,例如 Redis 和 MongoDB。我在容器内用 Shell 编写了一系列的脚本,在启动时从 Etcd 获取集群信息和自己的角色,然后通过长轮询完成配置的切换,再运行一个 Nginx 将从节点的流量转发给主节点,容器的数量则由 Swarm 保证,实现了一个「自维护」的数据库容器。


在去年 Node.js 错误处理实践 的基础上,今年我又在继续探索错误处理和日志的最佳实践。之前的方法存在一个问题,即我特别关注于将错误对象原样地传递出去,但有时看到一个非常底层、非常细节的错误(例如 CONNTIMEOUT),则难以判断究竟发生了什么。虽然从异常的调用栈中可以看出调用路径,但并不能看到一些关键变量的值,例如这个连接错误是在请求哪个地址,主要参数是什么,这是因为在异常传递的过程中,我们并没有记录这个信息。最后只能得到一个非常细节的错误信息,而不知道这个错误发生在更上层的哪个环节。

于是我开始使用 verror 这个库,它最主要的功能是帮助你创建一个「异常链」,你可以在每个层级来向异常上补充路径信息(会被反映到 err.message 例如一个来自底层的错误信息可能是 request failed: failed to stat "/junk": No such file or directory 这样)。这个异常链信息也会和其他元信息一起以结构化的方式存储在错误对象上,这个库也提供了一些工具函数来获取这些结构化信息。我尝试使用 verror 来管理所有的异常,报告带有详细的、每一层级信息的异常。同时我也会向错误对象上附加一些元信息用来指示如何响应客户端、是否需要发到 Sentry、是否可以重试等。

除了异常,我也开始尝试使用 bunyan 打印结构化的日志,并存储到 Elasticsearch。通过 Kibana 的 Web UI 可以很简单地对日志进行筛选和查询,在排查问题时找到相关的那部分日志。对于一个既有的系统来说,调整异常和日志可以说是一个非常庞杂的工作,在调整的过程中也我也在不断地修正自己的实践,今年一整年我都在做这样的尝试。


对于一个稍微复杂一点的项目来说,并不是所有的数据都在事务的保护下 —— 其实很多互联网项目也并不会使用事务。这样就难免出现数据不一致的情况,这种不一致可能是数据的关系出现损坏、缓存和数据不一致,也可能是多种数据库甚至外部资源的状态没有同步。

今年我探索了解决这个问题的一种实践:编写脚本去自动地检查和恢复这种不一致,这种脚本是常态化运行的,例如我的一个项目中现在有 4 个脚本以每 10 分钟左右的频率在进行各种检查和恢复。这样不一致的数据会在很短的时间内被恢复(也会留下可查的记录),对于用户来说就是碰到问题的次数变少了,在一些重大的的故障发生时,这种脚本也可以帮助你快速地恢复服务。

这样自动地修复不一致也引入了一个问题:就是在核心业务中会不自觉地降低对一致性的追求 —— 反正有脚本来修复,问题不会暴露出来。目前只能是为检查和恢复的情况绘制图表,在不一致的频率超出预期时及时地发现。


因为云引擎的 负载均衡 逻辑比较复杂,之前是在一个开源的 Node.js 反向代理组件上进行了一些二次开发,但在高峰时的性能不是很理想,一直有想法换成 Nginx。于是今年年初我就开始基于 Openresty 用 Lua 重写了负载均衡组件,效果非常理想,只用了 Node.js 十分之一的 CPU 和内存,再也没有出现容量不足的情况。

原因当然是 Nginx 对内存有着非常细粒度的管理,只在请求开始和结束时申请和释放整块内存,也没有 GC,保持一个长链接几乎不需要消耗多少资源。Openresty 则将 Lua 嵌入到了 Nginx 中,在 Nginx 高性能的请求处理和丰富的 HTTP 功能的基础上,让你可以用 Lua 去实现一些逻辑,对于负载均衡肯定是够用了。


我之前一直有在使用 pass 这个基于 GPG 和 Git 的命令行密码管理器,并将密码仓库托管在 GitHub 上。之所以用它是因为它基于可靠的开源工具、本身也是开源的,同时它足够简单,简单到我不需要它也可以操作我的密码。

也一直有想法为它开发一个 UI, 于是今年九月我用 Electron 开发了一个名为 Elecpass 的密码管理器,在机制和数据格式上与 pass 完全兼容。之前其实我并没有用过 Electron, 但上手的体验还是相当不错的,没有遇到什么问题。因为 Electron 自带了 commonjs 的模块加载系统,也不再需要像前端开发那样复杂的构建过程。

目前 Elecpass 一共发布了两个版本,虽然还非常简陋而且有一些 Bug,但已经可以满足基本需求了,我自己也一直在使用,明年我应该会为它添加更多的功能。


今年年初腾讯发布了微信小程序,我代表公司在「小小程序,大有作为」的线下活动里做了一个主题为「在微信小程序中使用 LeanCloud」的分享,在准备期间我也了解了一下微信小程序。

可以说微信小程序就是腾讯为了在微信中构建一个封闭的「操作系统」的产物,但大家迫于微信本身的平台能力,比如用户信息、推送、支付,不得不使用它。作为一个平台,微信小程序绑定了一个数据绑定框架,也绑定了一套模板语言,同时和前端现有的工具链(编译打包)的整合也非常差,很难利用现有的 JavaScript 生态。作为结果,我相信微信小程序不会有什么技术层面的社区和生态,只能作为最末端的用户界面。


年初因为发现我司的 服务状态页 年久失修,我决心重写一个服务状态页,参考一下 GitHub 等网站。我希望它能同时展示三个节点的状态、能够展示过去一天的历史状态、允许运维同事在服务状态页上快速地发布通知。最后这个状态页也开源了出来,在 leancloud/leancloud-status

为了能够让服务状态页本身总是保持可用,我设计了一个比较有趣的架构:后端(检查器)分别运行在我们三个节点的云引擎上,交叉对所有节点进行检查,将结果和展示历史图表所需要的数据写入到 S3(或其他对象存储上);状态页面作为静态页面托管在 CDN 上,从 S3 分别拉取三个节点的检查结果和历史图表数据,对来自三个节点的数据进行汇总,决定显示为「正常」还是「故障」。

这样就保证了服务状态页本身的可用性和三个节点隔离,可用性仅依赖于 S3(理论上可以同时写入多个对象存储作为热备),检测程序又运行在我们自己的云引擎上(比单独部署在一台机器上更易于维护),架构又并不复杂。

为了在前端合并三个节点的时序数据并绘制图表,我其实是费了很大的功夫的,但在实际部署的过程中遇到了很多细节的问题,做了很多妥协。例如我们的美国节点到国内的访问一直不畅等等,最后并没有把我制作的历史图表展示出来。


之前几个北京的同事写了一个 聊天机器人 放在公司的 IM 上,每天看他们调戏机器人觉得挺幼稚的。但等我搬到北京之后也加入了他们的队伍,我给机器人加了几个有趣的功能,虽然实现上并不复杂,但你可以通过聊天的方式把它展示给别人看,也可以让别人参与人来,还是个非常有意思的事情。

首先我写了一个 帮助大家决定晚上吃什么 的功能,这一写我就来了兴趣,后来又写了 确认大家是否都准备好吃晚饭了帮助运维同事简单地更新服务状态页,还 为公司免费午餐的福利随机人选

入门 Golang

我从 1 年前开始关注 Golang, 但一直没有时间细致地学习 Golang 的语法。

有关设计哲学,和语法的概览,我倒是看了不少,前两天买了一本《Go语言程序设计》,打算认真学习一下。

作为一个 C++ 出身的程序员,以及一个 Unix 粉,和一个开源主义者,Golang 的设计哲学相当得对我的胃口。

我对 Golang 的理解是“一个服务器端编程语言”,下面我来谈谈 Golang 吸引我的地方。

精心设计过的语法

这是和 PHP 比的,我之前一直在使用的 PHP 恐怕是流行的编程语言中,唯一一个语法没有经过精心设计的。

Golang 是由三位大牛共同设计的,它很年轻,没有历史包袱。

Golang 吸收了一些现代语言的语法,比如数组切片,多返回值等,但并不多,毕竟 Golang 的定位是工业级。

工业级

Golang 不是一个玩具,它甚至比很多流行的编程语言更加严肃,在和 Java 靠齐。

Golang 的语法设计就是为了让代码更加易读,而不是像 C++ 那样留给了大牛们很大的发挥空间。

Golang 自带代码格式化工具,要求所有代码都有一致的代码风格。

社区

Golang 的官网就是非常有活力的,首先就是一个 Hello World 和一个在线编译器。

目前 Golang 由 Google 牵头维护,包括 Google 在内的不少大公司都在使用 Golang.

文档,参考清晰地列在那里,第一时间提供给你了需要的信息,而且 Golang 还有个萌萌的吉祥物。

至于中文 Golang 社区的成长,也是相当迅速的,仅仅最近一年,就出现了两本完整的 Golang 中文教程,而且都是开源的。

本地代码和静态链接

知道 Linux 下最大的坑是什么么?就是动态链接库。

为什么 Linux 大部分软件都要求编译安装,就是因为动态链接库。

Golang 通过完全静态链接的方式避开了这个大坑。

同时 Golang 编译出的是本地代码,服务器无需环境即可运行。

没错,不需要任何环境,不需要任何动态链接库。

静态类型和接口

Golang 是一个静态类型语言,变量在编译时,就有一个唯一确定的类型。

因为 Golang 削弱了指针的功能,因此 Golang 甚至比 C/C++ 更加“静态”.

这使得 Golang 可以在编译时就进行完整的类型检查,在编译时就可以找到大部分的语法错误。

Golang 接口的创新在于,将描述接口的工作,从定义类的时候,转移到了使用对象的时候。

这使得 Java 等语言中原本繁琐的接口功能,在 Golang 中变得非常好用。

Golang 的类型系统建立在接口上,同时接口和静态类型检查也是紧密相关的。

简化的 OOP

C++, Java 中所谓的面向对象思想,略显复杂,Golang 则将 OOP 思想做了极大的简化,只留下了最核心的特征。

并发支持

Golang 的并发支持由 goroutine 和 channel 支持。

goroutine 是一种轻量级线程,目的是对 CPU 核心进行抽象,最大限度发挥多核心 CPU 的计算能力。

而 channel 是一种类似管道的线程间通讯机制,将经典的加锁模型,转换为了类似 Unix 进程间通讯的管道模型。

标准库

作为一个服务器端编程语言,Golang 的标准可以说非常全面,大部分的算法,协议都包括在了标准库中。

对于网络编程,Golang 几乎是开箱即用的。

而且 Golang 也借鉴了 Python 等语言的包管理器,安装软件包非常方便。

错误处理

Golang 没有传统的异常机制。

对于非致命的错误,Golang 使用返回值来报告(Golang 支持多返回值).

对于致命的错误,Golang 直接选择“崩溃”掉(当然也有恢复机制), 不过按照 Golang 的哲学,既然是致命错误,就应当挂掉。

还有 defer 关键字,用于将一个语句“绑定”到函数退出时执行,无论是通过各种途径退出,这可是 C/C++ 里面的大问题。

1

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.