我正在 SegmentFault 上录制一些 视频课程,欢迎购买收看,这是支持我创作更多技术内容的好机会哦。
基于业界最成熟的加密和版本控制工具 —— GPG 和 Git 的密码管理器:Elecpass
标签 #年度技术小结

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

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

2016 年度小结(技术方面)

今年年初我花了三个月的业余时间用 Laravel 开发了一个项目,在此之前,除了去年换工作准备面试时,我并没有正经地用过什么 PHP 框架。在我看来,Laravel 其实并没有太多的独创性,而只是把其他社区中那些被实践证明非常有价值的东西带到了 PHP 社区,例如自动测试、包管理、依赖注入等等。

回到 PHP 本身,PHP7 无论在语言特性还是性能上都无可挑剔,我觉得限制它的使用的更多的是在异步模型上 —— 或者说它缺少一个好的异步模型。也许有人觉得 Node.js 的事件队列十分难以捉摸,但由于 Node.js 是我在生产环境使用得最多的语言,我十分熟悉 Node.js 的异步模型,对其他「普通」的语言的异步模型却比较难以接受。今年我在 Openresty 中写了一些 Lua 代码,对 Lua 的协程也有了一些简单的了解,同时我也了解到在 Python 中被使用最多的异步模型同样也是基于协程。

协程最大的好处是不会对代码有侵入性 —— 你写代码的时候依然是照常地写同步代码,只需在执行的时候引入协程(对于 Web 服务就是在每收到一个请求的时候启动一个协程)就可以享受轻量级进程的好处,用更低的 CPU 和内存开销来支持更高的并发。相比之下 Node.js 的异步代码则是显式的,需要时刻考虑异步的问题,也很容易出现疏忽和错误。而且异步的函数是有传染性的,如果你调用了一个异步函数,那么后面的代码都要用异步的方式写,虽然我们有 Promise 等异步抽象,但这其实就是侵入性的体现 —— 你必须选择一个异步抽象,这个异步抽象会混入你所有的代码。

那是不是说 Python 的协程就比 Node.js 的事件队列要好呢?其实我觉得大部分的情况下是这样的,但从理论上来说,Node.js 还是会有更好的性能,因为协程虽然比线程要轻量级,但依然是一种封装 —— 需要在协程之间调度,需要保存和恢复执行上下文,而 Node.js 的异步是没有任何的封装的 —— Node.js 里你没有办法去管理异步任务(domain 模块也早就被 deprecated 了),因为异步任务并不是运行在像协程的这样一个容器里的,因此省掉这一层抽象会带来更好的性能。

Laravel 是一个一站式的开发框架,我之前并没有用过这类的框架,在 Node.js 中也不是很流行这样的框架,好处自然就是组件之间有着很好的兼容性和一致的设计,质量也有保证;劣势则是选择的余地比较少(实际上服务容器的概念就是为了弥补一站式框架不便于更换组件的问题),因为这些组件是框架整体的一部分,所以对一些比较小众的需求缺乏支持,这些组件也较少考虑脱离 Laravel 独立地使用。


今年我还花了很多时间去了解和使用 InfluxDB 这样的时序数据库,在 Web 后端的开发过程中,有很多日志、监控、统计类的数据都是时序数据 —— 这些数据量会很大、每一条都和一个时间关联、查询时通常也按照时间范围进行查询、查询时我们通常会将一段时间的数据进行分组和聚合。InfluxDB 为了支持基于 Tag 的筛选和分组,采用了按列存储的方式 —— 每个 Tag 的值的组合都被称作一个序列,被独立地存储以备检索。

因为这些数据都和时间关联,同时数据库也提供了比较好的对分组和聚合的支持,所以可以很轻松地使用 Chronograf 或 Grafana 之类的可视化工具画出图表,对这些指标进行监控。实际上去年我做了一个和时序数据库非常相关的项目,即 leanengine-sniper,今年我又花了一些时间把这个算是自部署的系统包装成了一个云服务(LeanEngine APM),实际上这就是一个针对特定场景的、简易的时序数据库和基于时序数据的可视化工具。


做一个象棋 AI 是我一直以来的一个想法,于是今年接着 AlphaGo 的热度,我用 TypeScript、React 和 Web Worker 等技术在浏览器中实现了一个非常弱的国际象棋 AI —— Wizard Chess。其实只能说是把关键的组件都实现了出来,但实际上 AI 走的每一步棋都很弱。显然这是一个对性能非常敏感的项目,根据我去年的经验要尽可能避免对数据的修改,但在我调研了 Immutable.js 之后我并没有选用它,因为我觉得可能我不需要它提供的那么复杂的数据结构,而是自己在编码时注意不要修改参数、函数总是返回新的对象即可。

在 Wizard Chess 中我也试用了 TypeScript,它给 JavaScript 实现了编译期的类型检查,这会非常有助于在编译期发现和类型有关的错误,但并不能做到真正的静态类型语言的那种程度,尤其 JavaScript 中存在大量取值为 null 或 undefined 的情况,同时需要为所使用的库找到定义文件也是一大痛点。

到目前大家已经普遍认为 TypeScript 比 CoffeeScript 有着更好的前景,CoffeeScript 这个项目也显得有些疏于维护了,这令我非常痛心。虽然 ES2015 已经补齐了 JavaScript 语言本身的一些短板,但我觉得 CoffeeScript 还是有它独特的价值的,比如用缩进区分代码块、更少的括号,以及通过问号进行空值判断。


为项目撰写 HTTP API 文档一直是一个很纠结的事情,为了他人阅读和理解容易,应该详细地列出所有参数的细节,但这样又会导致文档有大量重复的内容,维护将会十分困难。用来生成 HTTP API 文档的方案有很多,我最后选择了 RAML —— 这是一个基于 YAML 的用来定义 HTTP API 的语言,它提供了非常多的特性(type、resourceType、trait)来对定义进行复用,你可以根据你的代码的架构去组织这些定义(例如如果几个接口都挂载了同样的中间件,便可以使用一个 trait 来定义这个中间件的行为),最后通过一个编译环节生成可以阅读的 API 文档。

后来我还了解了一下 GraphQL,并实现了一个 在 LeanCloud 上使用 GraphQL 的 Demo,GraphQL 解决了 RESTful API 缺乏范式、类型不够严格、对关系数据支持弱、难以发现等问题。但目前对 GraphQL 的应用仍非常有限,我想大概是为了支持 GraphQL 需要服务器端进行很大的改动,同时引入这样一层复杂的抽象,也会带来很大的性能开销,可能 GraphQL 更适合的场景会是 BaaS 和开放 API 吧。


如果说我在番茄土豆学到的是如何开新坑、如何以开放的心态去接受新的技术的话,在 LeanCloud 这一年多则是去长时间地维护一个复杂的系统,在保证兼容性和可用性的前提下,渐进式地对系统进行改进,引入新的技术。之前在番茄土豆的时候,也是因为自己的水平提高得比较快,经常想要进行一次彻底的重构 —— 其实应该称之为重写才对。但往往没有好的结果,因为这种大的重写会花费很多的时间,会导致新版本和原有代码差别越来越大,甚至更换了新的语言或数据库,很难保证和原有代码有着一样的行为。同时因为数据操作的不兼容,在上线时也必须一下子全量上线,结果就是导致最后的上线时间一拖再拖。

今年上半年云引擎也有过一次很大的 改版,允许用户对实例进行更多的管理以及大量的内部重构,其中我得到的经验就是要进行渐进式的重构 —— 将大的修改划分为若干个小任务,逐步地将这些小修改上线进行测试,而不是一次性上线一个大的修改。让新旧代码混跑一段时间,保证新旧代码对数据的操作是互相兼容的,虽然在开发上需要实现很多过渡代码,在过渡完成后还需要清理这部分代码,但这种出现问题可以随时回滚的能力会让你对上线新的修改非常有信心,反而能够加速整体的重构进程。

今年在 LeanCloud 我也开了几个新坑,的确新的项目在发布或上线之前的进化速度是非常快的,一旦有好的想法就可以立刻实施而不必顾及兼容问题,甚至也可以跳过很多的测试和 Code Review。有句话是说「不要过早的进行优化」,但我有些怀疑这个观点,在正式发布或上线之前很可能是最好的优化时机,至少要考虑到后续的优化并预留出修改的空间。一旦项目上线,那么每个修改都要有充分的理由、都要去顾及兼容性并进行全面的测试,这时再进行优化会是非常低效的,我也的确遇到一些项目是因为最开始的设计失误导致后期几乎没办法去优化。


随着 Docker 生态的发展,「微服务」是个比较火热的话题,但大都还是一些方法论,没看到太多具体的实践经验。目前我对微服务的理解主要是两方面,一是对项目进行拆分,减少单个开发者需要接触的代码量;另一方面是对服务进行隔离,缩小故障的影响范围,更好地进行水平拓展。今年我也基于这两个出发点进行了一些实践 —— 项目的不同服务使用同一个代码库,互相共享很大一部分代码,包括自动测试也是在一起运行的。但每个服务有着不同的入口点,会被单独地部署和运行。

大概这是一种不够彻底的微服务吧,我知道当然可以将共同的代码发布为单独的包,来实现更加彻底的拆分。但把一个组件独立为一个包其实是一个很严肃的事情,可能需要它有单独的仓库、文档、版本号,当 API 发生变化时还要考虑兼容。对于一个还没有那么复杂的项目来说,这个开销可能会很大程度上影响迭代速度,所以我还是选择使用同一代码库,这样进行修改时会更加灵活,通过自动测试来保证修改不会引入问题。

如果已有服务化的基础设施,这种服务的拆分其实还是很容易的,但如果从头搭建一套微服务的基础设施则还是需要一些工作的。例如为了管理不同服务的配置,标准化部署过程,你需要一个 CI;为了提供不同服务所需要的环境、在运行时进行隔离,你需要有一个容器引擎;为了管理和调度容器、充分利用资源,你还需要一个集群管理器;为了能够平滑地进行部署,你还需要服务发现和负载均衡;为了收集和检索日志,你还需要一套日志收集和分析器;更别提还有的统计、监控和报警需求了。对于这些基础设施我也有自己的一些实践和看法,我还是比较期待新的一年里能在业余时间按照我自己的选择去搭建一个这样的微服务平台,大概也算是给 RootPanel 划上一个句号。


很时候服务不可用都是因为数据库的问题导致的,不同于无状态的负载均衡或应用容器,数据库存储了所有的状态,这意味着你不能简单地重新创建一个数据库实例,而必须要顾及到其中的数据。为了保证数据库的可用性,最简单的办法就是运行多个数据库实例,互相之间同步数据,在故障时切换到另外一个实例。但这样又会引入新的问题,如果发生网络分区怎么办?于是我开始深入地了解 CAP 提出的分布式系统的限制,了解 Paxos 这样的算法如何在分布式的系统中达成共识,也了解了各种数据库提供了怎样的分布式能力和怎样的高可用解决方案,这样在以后为项目选择数据库的时候应该会更加有针对性。

在这个过程中我也读到了「SRE: Google 运维解密」新出版的中文版,书中介绍了作为世界上最大的互联网公司,Google 是如何在规模迅速增长的情况下继续保证服务的可用性的。书中介绍了很多原则和方法,读完这本书让我觉得热血沸腾,相比于写代码实现确定的需求,也许去应对未知的故障会更有趣?但在此之前可能还有很多知识需要学习,首先在一番纠结后,选择了 Ansible 作为配置管理工具,开始尝试将我的服务器上的所有服务都通过定义文件进行描述,这项工作持续了将近三个月的时间,到现在还未全部完成。用一组定义文件去代替对服务器的直接操作,这样的好处是非常明显的 —— 这些文件可以进入版本控制让所有修改有据可查,可以随时应用在新的服务器上,也可以随时在已有的服务器上进行验证和重放,通过 Ansible 这类工具所提供的特性,也可以对这些配置进行高层次的抽象,来管理更复杂的配置和大量的服务器。

我们不光要使用不可变的数据结构来控制可变状态,像服务器这样的基础设施也可以让它们变成「不可变」的。其实 Docker 的容器就是一个很好的例子,所有的容器都是从 Dockerfile 生成出来的,当你需要修改容器中的运行环境的时候,你不是直接在容器内进行修改,而是去修改 Dockerfile —— 因为它是容器的模板。我们也可以总是通过定义文件来描述基础设施,每次修改后都重新验证服务器与定义文件中的描述一致,这样我们便不必关心服务器上的状态了。也不会出现服务器多人维护,配置混乱难以迁移的情况了,它永远看上去和新的一样。


此外今年我还公开或半公开地做了五次技术分享,准备每个分享都花了我起码半个月的时间,其中的四次已经被我整理成了文字版本:

1

精子生于 1995.11.25, 21 岁,英文 ID jysperm.

订阅推送

通过邮件订阅精子的博客日志、产品和项目的最新动态,精子承诺每一封邮件都会认真撰写(历史邮件),有想和精子说的话也可以直接回复邮件。

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-SA 3.0  授权发布。最后生成于 2018-06-13.