每年末,Strikingly 技术团队都会回顾过去一年的技术上取得的成果,也会制订新一年的路线图。今年,我们选择把部分内部资料分享出来,希望和社区一起交流一下我们看到的趋势和 2017 年的技术目标。

关于软件设计,我们一直持有这样一个观点:the core of software development is managing complexity,软件开发的核心是管理复杂度。Strikingly 在 2012 年刚上线的时候还只是一个非常典型的,运行在 Heroku 上的 Rails 应用,产品已经初具形态,并拥有一个规模还不错的用户群。随着 4 年多来产品特性、辅助功能的增长和用户数量的增加,整个系统的复杂度日渐成为整个技术团队需要面对并解决的挑战。

例如,我们的域名系统需要管理用户从我们这里购买的域名从查询、注册、验证到续费的整个生命周期,还需要自动配置 DNS 关联用户网站。在这些管理流程中,每个 TLD(Top Level Domain,顶级域名)都有自己不同的要求,对于不同 TLD 的域名我们也有不同的定价策略。同时,这套系统必须要设计合理使得它可以对接多个域名提供商和 DNS 服务提供商,同时支持 Strikingly 和上线了。

除了域名系统之外,我们的电商模块、CRM 系统、支付订阅系统等都面临类似的复杂度挑战并需要同时支持 Strikingly 和上线了。这些需求都对系统的灵活性提出了非常高的要求。

总结下来,我们认为挑战主要来自于以下几点:

我们很高兴看到,对于以上每一个挑战,我们在过去的一年都已经有了明确的答案,有些已经部分解决,有些已经全部解决,没有解决的部分我们在新的一年继续努力。欢迎大家加入和我们一起努力:Strikingly 2017 技术团队招聘

导读:

对 前端/iOS/Android 感兴趣的可以阅读:
第一部分 “从前端 + 移动端到客户端” 面向的是做前端和移动端开发的工程师

对 后端/DevOps 感兴趣的可以阅读:
第二部分 “服务端架构的思考” 包含了我们在后端 API 设计,代码组织和系统架构上的思考
第三部分 “可靠的基础设施” 介绍了我们的开发运维团队在基础设施自动化管理和工具开发上的心得

对 自动化测试 感兴趣的可以阅读:
第四部分 “自动化回归测试” 描述了我们摸索出的一套做自动化功能和 UI 回归测试的流程

希望能够让各种不同职位的工程师和技术管理人员各取所需,更希望能够借此机会跟各个领域的牛人交流心得。

从前端 + 移动端到客户端

为了应对多客户端(Web, iOS, Android)的挑战,2016 年我们在团队层面和技术栈上做了很大胆的尝试:我们把前端团队和移动端合并了,组成了客户端团队。这个团队采用同一套基于 React 体系的技术栈去构建 Web、iOS 和 Android 应用,可以跨端复用很多业务逻辑代码。这次变革让我们体会到了统一技术栈在开发效率、团队协作和知识共享等方面带来的提升。

实际合并的过程是 2016 年 5 月开始的,在此之前我们做了很久的铺垫准备。整个过程分为三个阶段:

第一阶段:React Web

熟悉我们团队的人应该知道我们在过去两年累积了很多使用 React 开发大型 Web 应用的经验。我们的 Web 应用是一个建站平台(上线了,https://www.sxl.cn),整个网站编辑器就是一个交互非常丰富的大型单页应用(Single Page Application)。React 以及其社区的几个核心思想:组件化、单向数据流、纯函数 UI、不可变数据,大大简化了构建这种大型 Web 应用的过程。我们也想把这种开发体验通过 React Native 带到移动端开发。于是在 16 年 3 月份,我们进入了第二阶段。

第二阶段:React Native

在 16 年 5 月,我们开始使用 React Native 构建 “上线了” iOS 应用。这款应用可以让用户直接通过手机建立网站,管理商城订单和留言,用户不再需要电脑进行这些操作。在这个应用的开发过程中,我们把 native 和 web 可以共用的代码,包括业务逻辑和通用的工具辅助类代码从原来的 React Web 代码里抽出来,放到 common 目录下方便 native 和 web 复用。整个项目前后花了 3 个月左右就把 iOS 和安卓应用写完了。iOS 和 Android 代码重用达到了 90%,其中也包括不少 Web 端也可以共用的代码。

第三阶段:跨平台开发

基于前两个阶段的铺垫,我们有了比较深厚的 React 开发经验,也写了很多跨平台的业务逻辑。这些业务代码只要接上不同的视图层就可以开发出在不同平台上的应用了:对于 Web 应用,视图层就是 React.js,对于移动端应用就是 React Native。两者的开发体验非常类似的,我们甚至可以让同一位开发者去开发 Web,iOS 和安卓的应用。

因此,第三阶段我们从团队结构上重新进行了分配。之前,对于每一个功能模块(feature),在不同平台上都需要不同的开发者去完成。

而现在,我们可以根据功能模块去分配。一位全端开发者独立把 Web、iOS 和安卓平台都做出来。

三个阶段总共用了两年时间。从最初听到 Facebook 的工程师畅想着通过 React 可以开发不同平台应用到现在我们团队已经实现了这个目标,我很庆幸这是客户端工程师一个美好的时代。17 年,我们将基于这个团队结构和技术架构,更快速的做出新功能模块!

(我司 CTO 在 JSConf 2016 做了一次关于全端团队搭建过程的分享,有兴趣的可以在这看视频

服务端架构的思考

GraphQL

在构建大型前端应用时,客户端和后端工程师通过 API 的方式进行合作,API 就是双方通信的协议。现在主流的 API 设计范式是 RESTful API,然而在实践中,我们发现 RESTful 在一些真实业务逻辑的需求下不是很适用。为此我们往往需要构建自定义 API 节点,而这违背了 RESTful 的设计理念。

我们认为 Facebook 的 GraphQL 是目前最接近完美的解决方法。服务端只需要定义好业务逻辑中设计的数据类型系统,客户端工程师就可以使用 GraphQL 自定义查询的数据及其结构,大大地提升了 API 的灵活性。关于 GraphQL 可以具体解决哪些问题,可以读一下我司 CTO 前年在 CSDN 上发布的文章,在这就不仔细阐述了。

自 2015 年 7 月推出 GraphQL 规范到今天,除了 Facebook 之外社区里不乏有些先锋开始陆续使用起来。其中知名的包括有 GitHub,Pinterest,Coursera 和其他。Meteor 这家公司也是先锋中的其中一员。Meteor 作为一家在 Web 应用领域一直有一些超前做法的公司,在 2016 年全力投入了大量资源打造基于 GraphQL 生态系统的 Apollo。Facebook 本身也投入了一整个团队把内部的旧 GraphQL 系统升级到对标社区规范的新系统,并发布了几个在实战中使用到的工具。

鉴于 GraphQL 目前在社区生态上已经比较完善了,2017 年我们将开始使用 GraphQL 渐渐替换掉内部已有的 RESTful API。

构建复杂的 Rails 应用

Strikingly 服务端代码主要是基于 Ruby on Rails 开发的。Rails 的设计哲学是 おまかせ (Omakase),它提倡和遵循 “惯例重于配置”(Convention over configuration)的理念,并提供了丰富的工具链,让 Web 开发人员非常容易上手。Rails 的惯例大多是 Web 开发领域多年总结下来的最佳实践,即使是新手,也能够在短时间内开发出安全,健壮的 Web 应用,这个对于初创企业来说是非常有帮助的。

但是当应用的逻辑开始变得复杂的时候,Rails 就开始显得力不从心了,它所提供的惯例和最佳实践没有办法再很好地指导开发人员写出具备高可维护性的代码。

比如说,Rails 的 MVC 里,View 的职责都是非常明确的,Rails 社区也有 “Fat Model, Thin Controller” 这样的指导原则来划分 Controller 的职责,但是 Fat Model 到底给了 Model 什么责任,没有人说得清楚。更多的时候,Fat Model 就意味着但凡不属于 Controller 的责任,一概扔到 Model 里去。一个典型的 Rails 代码结构里,除了 View 和 Controller 之外,业务逻辑也也只能放到 Model 里面去。在领域相关的逻辑代码规模庞大,复杂度高的情况下,如何设计 Model 并保持其代码可读性呢?Model 在不同的上下文环境中可能有不同的行为,如何处理?涉及两个或两个以上 Model 的逻辑代码应该写在哪个 Model 里?

要解决这问题,我们需要重新审视 Rails 在 Web 应用开发中的定位。Rails 只是一个 Web 框架,它不是一个应用开发框架,不能也不应该负责 Web 应用中领域相关的部分。我们不妨从 Java 社区借鉴 POJO (Plain Old Java Object) ,引入 PORO (Plain Old Ruby Object) 的概念。

在我们总结的设计模式中,一个 PORO 对象就是一个普通的 Ruby 对象,它的 initialize 方法除了提供其他 PORO 对象的依赖注入(DI,Dependency Injection)之外不包含任何参数,对象本身也不保留任何状态。在应用中,一个 PORO 对象通过工厂类产生,工厂类负责完成依赖注入,在这个过程中,可能需要调用其他 PORO 的工厂类来产生对象。

为了解决 Strikingly/上线了应用中不同场景下的不同问题,我们使用了以下 5 类 PORO 对象:

Service 对象的功能是描述领域相关逻辑中的操作或过程。比如一个典型的 Web 应用中支持用户注册、登录、登出等操作,有些应用可以支持用户付费升级和取消套餐等等,这些操作都是适合用 Service 对象来描述的。

Form 对象提供了介于用户界面上的表单和 Model 定义之间的一层封装。Rails 本身提供了简单易用的表单,但是 Rails 的表单跟相应的 Model 之间有非常强的耦合性,这样等于说把应用的 Model 层实现细节直接暴露给了用户,非常不灵活。Form 对象替代了真正的 Model 层来作为表单的 Model 层,把用户输入转换成真正的 Model 对象。

Policy 对象和 Query 对象相对比较简单,它们分别定义了封装权限检测逻辑和数据查询逻辑的对象。

Adapter 对象提供了介于应用内部领域相关接口和应用外部依赖接口之间的一层封装。应用内部领域相关的逻辑应该有自己固定的接口并不与外部依赖接口之间产生强耦合关系。比如 Strikingly 提供域名的购买和管理服务,这个服务提供了域名查询、购买、验证、续费、取消等操作,这些操作都是域名这个领域内的 “标准操作”,并不依赖于我们的上级域名提供商。我们应该允许上级域名提供商修改它们的 API,允许切换到另一个上级域名提供商,甚至支持多个上级域名提供商。在有 Adapter 这一层封装之后,我们可以做以上这些更改而不需要更改任何域名领域内的操作代码,需要修改的仅仅是 Adapter 对象的代码而已。

今后随着系统的复杂性进一步增加,我们可能会使用更多的 PORO 对象类型来解决新的问题。我们相信,通过这种方式,可以有效地降低我们系统内部模块之间的耦合性,使得代码的可维护性大大增强,也更方便我们编写高效的测试代码。

关于这一部分的详细内容可以参考我们团队的资深 Rails 工程师 Florian Dutey 在 RubyConf Taiwan 2016 上的演讲 “Large scale Rails applications”。

微服务架构的演进方向

PORO 对象和依赖注入可以很大程度解决单个应用中业务复杂性造成的可维护性问题,但是应用规模、复杂度和用户数目的增加还带来了其他问题,对于这些问题,我们必须从系统整体架构上做调整来解决。

首当其冲的就是用户网站的高可用性。Strikingly 建站产品的特性决定了用户会对自己的网站有较高的可用性要求,对网站的管理平台和编辑器的可用性则没有很高的要求。这就需要从系统层面做切分,对用户网站所依赖的功能提供不同于管理平台和编辑器的 SLA(Service Level Agreement)保证。

其次,应用中的每个模块都有自己独特的流量特性,而单一应用则决定了比较单一的技术栈和通信、数据存储等方面服务的选择。例如用户网站的流量特性就跟管理平台和编辑器的流量特性完全不一样,每个用户网站的流量特性也不一样。即使在用户网站中,不同的功能模块因为功能的区别和使用率的不同,流量特性也不完全一样。如果能够对于每个模块采用各自最适合的技术方案,就可以最大化模块的性能。

再者,单体应用导致所有大大小小的改动都必须重新部署整个代码库,而为了保证新代码的正确性,部署之前需要对整个项目的前端和后端代码进行自动化测试,整个流程持续时间很长。每个小的改动都重新部署整个代码库代价太大,而如果降低部署的频率的话又会在一定程度上影响迭代效率。

解决这些问题的有效方案是将目前的单体应用合理地拆分成为多个微服务。例如,所有用户网站相关的服务都会被拆分出来作为微服务,并且每一类网站模块所依赖的服务都会成为单独的微服务。这样可以保证用户网站整体的高可用性,可以允许因为后台维护的原因出现某个服务短时间不可用,极大地降低网站整体上服务不可用的情况的出现。在微服务架构下我们可以为不同的服务选择不同的技术方案,并且各个服务可以分开部署。由于每个服务都非常小,测试和部署需要花费的时间非常短,可以极大地提升迭代效率。

向微服务架构演进是一项非常复杂的系统性工程,这也是为什么我们从几年前就开始思考引入微服务架构的可能性,但是因为当时的痛点还不大,并没有付诸实施。目前我们正在做关于微服务的元研究(meta research),也就是在研究为了迁移到微服务架构,有哪些具体技术方向的研究需要完成:

如下图所示:

这张图是目前总结下来的微服务领域我们需要研究的具体技术方向,随着实践的深入可能会添加新的内容。

2017 年我们把微服务架构作为一个主要目标,并不是在这一年中需要完成微服务架构的迁移,而是在这一年中把大方向和一些重要的细节确定下来,完成必要的技术储备,并且完成几个最重要的微服务的迁移;而整个架构的迁移完成可能会需要持续 2~3 年的时间。我们在这个领域仍然比较缺乏经验,希望能够借此机会跟领域内有丰富经验的工程师多交流,更希望能够在这一年中找到能一起在未来 2~3 年内一起完成这个大工程的人才。

可靠的基础设施

Strikingly 最初是部署在 PaaS 平台 Heroku 上的,Heroku 负责分配和管理下层基础设施,我们只需要关注在应用本身。2014 年我们从 Heroku 迁移出来,直接基于 AWS 构建服务。当时我们引入了刚刚兴起的 Docker 容器技术作为应用的打包部署方案,应该说是比较前卫的,但是在 AWS 基础设施的管理上,包括 EC2,VPC,Subnet 的分配和设置等等,还是采用手动管理的方式,在计算资源需要伸缩的时候也是通过手动方式进行操作。这样的管理方式带来了不少问题。

首先,手动操作容易造成操作错误,尤其是在维护正在运行应用的基础设施的过程中,如果不小心关掉了某台服务器或者设置网络的时候规则设置错误,都可能造成服务中断,影响用户使用。

其次,手动操作效率比较低。我们除了生产环境之外,还有多个沙盒环境供线上测试使用。为了保证测试的有效性,这些沙盒环境都要做到尽量跟生产环境一致。对于一个运维工程师来说,手动创建完成并测试通过一个沙盒环境往往需要 2~3 天的时间,并且无法完全保证这个沙盒环境和生产环境的一致性。

再次,生产环境和沙盒环境的当前状态非常不透明,即使使用文档记录了环境创建的操作步骤和当前的状态,也很难保证文档和环境之间一直保持同步。这样我们就无法有效地了解当前的环境是否符合我们的预期,很可能会出现考虑不周全的情况。

为了解决这些问题,我们需要更有效的方式来管理我们的基础设施。

基础设施即代码

我们选择的解决问题的方向是基础设施即代码(Infrastructure as Code)。所谓基础设计即代码,就是指开发和运维工程师不再使用手动方式或者过程式的脚本来分配管理基础设施,而是采用声明式的代码来定义基础设施,这里的代码既可以作为定义基础设施的文档,也可以运行来完成基础设施的配置,还可以作为自动化测试的 spec 来测试当前的基础设施配置是否符合预期。

基础设施即代码提供一种全新的方式来看待云计算时代的运维工作。传统的 IDC 时代,很多运维工作需要通过手工的方式来完成,手工操作的缺点在上文已经提过了。有一些自动化意识比较强的公司和个人,会采用过程式的脚本来自动化大部分的运维工作,确实减少了手工操作带来错误的可能性以及带来了效率的提升。但是复杂的脚本同样带来了不透明的问题:你怎么确定这个脚本运行的结果是符合预期的?如何测试脚本的正确性?如何保证脚本运行的幂等性?

基础设施即代码通过声明式的配置代码解决了这些问题。这些配置定义了我们所期望的状态,而运行这些配置的过程,则是不断地检测特定的计算资源是否符合定义,如果不符合,则通过调用云平台的 API 来操作使得该计算资源符合定义。

我们最初选择了 Ansible 作为配置基础设施和自动化部署的解决方案,基于 Ansible 设计和实现了一整套运维工具。这套工具帮助我们实现了两个重要的目标:

第二点在我们准备在 AWS 中国区域推出 “上线了” 服务的时候起到了很大的作用,我们几乎一键完成了整个生产环境和几个沙盒环境的基础设施配置,并且照搬了整个应用部署/回滚的方案,节约了大量的时间。

随着系统复杂度的增加,我们渐渐发现 Ansible 虽然在实现自动化部署方面很好用,但在定义和配置基础设施上并不那么方便,不能完全解决上面提到的 3 个问题。经过研究和比较,我们选择了 Terraform 作为新的基础设施配置解决方案,并使用 Terraform 完全重写了所有的配置代码。现在我们可以在任何时候重复运行这些配置代码来把基础设施更新到最新定义,并且使用这些配置代码很快地创建新的沙盒环境来满足多个产品团队并行测试的需求。

容器的编排和集群管理

基础设施即代码很好地解决了基础设施的管理问题,但是并无法解决基础设施之上的应用的生命周期管理问题。在确定微服务架构的演进方向之前,我们已经做了一些分布式计算的尝试,即将一部分相对独立的模块拆分出来作为独立的服务存在。我们引入了 Docker 容器技术来管理服务的打包和部署,每个独立服务各自分配和管理自己的基础设施和计算资源。

这种方案决定了系统整体的基础设施复杂度将随着服务数目的增加而线性增长。对于每个独立的服务,我们都需要单独的配置文件来定义它的基础设施,并且需要对这些基础设计进行维护和监控。同时,我们对于每个服务预留了一定流量爆发性增长的弹性空间,但不同的服务之间无法共享这些预留的弹性计算资源,也造成了一定的浪费。

未来演进到微服务架构会需要我们有能力管理更多更细粒度的服务,而目前通过管理基础设施来管理服务的方式将面临非常大的局限性。我们需要一个方案来解耦基础设施和服务之间的直接关联。

具体来说,我们不仅仅需要容器来封装服务和它的运行环境,我们更需要一个容器调度、编排和集群管理方案,可以帮助我们管理下层的基础设施和计算资源,并作为资源池的形式提供给上层的服务容器消费。服务容器并不关心下层基础设施的管理和分配,基础设施也并不随着上层服务数目的变化而变化,只要基础设施提供了足够的计算资源给上层服务使用就可以。同时,这个方案中需要包含一套命令行工具甚至 Web 界面来让工程师更方便地创建和管理服务的生命周期。简而言之,我们需要搭建一个简单易用的内部 PaaS 平台。

目前容器领域内比较成熟的容器调度编排方案有以下几个:

这些主流的容器调度编排方案都支持 Docker 的容器标准,目前我们正在测试和评估这些不同的方案,预计在 2017 年完成 PaaS 平台的设计和实现。

自动化回归测试

为了在快节奏迭代部署的同时保证产品可用性的稳定,2016 年我们搭建了一套完整的自动化测试方案:从单元测试、集成测试、功能回归测试到 UI 回归测试。

我们 CI/CD 的流程是这样的:

单元测试和集成测试将会在 CI Tests 时跑。功能回归和 UI 回归测试就需要等待代码部署到沙盒环境后才会在 E2E (End-to-End) Tests 环节跑。在此之前,我们在单元测试和集成测试上已经下了很大工夫了,2016 年重点解决的问题是搭建功能回归和 UI 回归测试。

功能回归测试

功能回归测试我们选用了来自蚂蚁金服的一套基于 WebDriver 协议的测试框架 - Macaca.js。WebDriver 协议对各种设备的交互已经制定了 API 规范,Macaca.js 也严格遵守了这套协议并为 Web、iOS 和 Android 都实现了对应的驱动。这就意味着我们的 QA 工程师只需要学习 Macaca.js 就可以测试 Web,iOS,Android 的应用。

除了使用代码做自动化的功能回归测试,我们也用了 RainforestQA 众测(crowd testing)方案。在这个平台上,我们的 QA 工程师和产品团队只需要写好测试用例的步骤(steps),平台会自动分发给有测试经验的测试人员进行手动测试。

步骤定义:

步骤定义:

RainforestQA 是一个 24 小时都在运行的服务。相比于团队内部成员手动测试,这个方案平均把测试所需的时间缩短到原来的二十到三十分之一。也就是说,之前一位 ******QA 团队成员需要一天才能完成的测试,使用 RainforestQA 就可以在一小时内完成

UI 回归测试
对于一款建站工具,在快速迭代的过程中,保证用户通过我们工具做出来的网站 UI 一致也是很重要的需求。我们采用了 UI 截图比对回归测试。在部署到沙盒环境上后,我们会做一些截图然后和上一次的截图(base image)做比对并高亮出两图之间的差别,只要截图有偏差就会报错并通知工程师和 QA 工程师进行排错。

在过去一年中,我们搭建了一套完整的从代码到部署的自动化流程,并完成了了一部分的功能回归测试用例。2017 年我们的目标是基于这套流程达到 90% 的功能回归测试覆盖率。

总结

总结一下,我们希望在 2017 年实现以下目标:

Strikingly 崇尚用技术解决问题,以技术驱动产品,我们坚信,一个伟大的产品背后,一定有坚实的技术基石。这些技术目标的实现离不开一个自由,创新,充满黑客精神的技术团队。希望在 2017 年有更多的小伙伴能够加入我们,一起实现这些目标。欢迎大家加入和我们一起努力:Strikingly 2017 技术团队招聘


↙↙↙阅读原文可查看相关链接,并与作者交流