本案例研究系作者投稿,由 Titouan Galopin 撰写,他是 EnMarche project 的技术和产品带头人。请注意这是一篇严格意义的技术文章;任何政治评论将被删除。

想让你的公司在Symfony官方博客上做主角?发送一个提议或案例到 fabien.potencier@sensiolabs.com


项目背景

2016四月, Emmanuel Macron,现任法国总统,创建了一个政治运动 名为 "En Marche!" (译成英语是 "On the Move") ,打从开始就是要挨家挨户向公众询问“法国有什么问题”。

不同于组建政党,En Marche! 并没有任何基础、预算或成员,来支持其动机。这也是为何 En Marche! 要仰仗互联网的力量,因为它还处于寻找支持者、组织活动和募捐的极早阶段。

我为 En Marche! 工作是以志愿者身份始于2016年10月。团队很小,所有it相关操作仅由一人维护。因此他们非常高兴地接受了我的建议来帮助他们。那个时候,平台还是用WordPress创建的,但我们需要用某些东西来替换它,以便能够更快、更易定制地开发。选择Symfony再自然不过:它很好地符合了网站规模,我有相关经验,并且它还能轻松升级来应对我们所拥有的大量用户。

架构一览

规模化是本项目的首要考量,特别是在他们面对“并非用Symfony开发”的平台第一版所带来的问题时。下列图片展示出项目架构的概览,其超大规模和冗余是必须的:

我们使用了Google Container Engine和 Kubernetes 来提供规模化、滚动升级和负载均衡。

Symfony应用是作为Docker程序从零构建的。配置信息使用了环境变量,并且为了大规模而将程序设为只读:在container实时运行时我们不生成任何文件。程序的缓存是在构建Docker镜像时生成的,然后使用 Symfony Cache component 配合一个Redis实例在服务器中进行同步。

另有两个workers,其中一个是由RabbitMQ提供,来处理背后进行的重载操作:发送邮件 (有时我们不得不在一次请求中发送4万5千封邮件), 另外构建序列化了的JSON用户清单,用于程序的若干部分,以避免进行缓慢而复杂的SQL查询。

数据库使用的是Google Cloud SQL,这是个中心化的MySQL库,毋须我们来管理。要连接它,我们使用了 Cloud SQL proxy Docker image

部署

项目使用的是一个Continuous Delivery(连续传输)策略,它不同于Continuous Deployment(连续部署)方案: 每一次提交都被自动部署到一台staging server上,但是产品级部署却是手动的。Google Container Engine 以及 Kubernetes是我们流程的核心组件。

Continuous Delivery进程,连同功能和单元测试,是被CircleCI所控制的。我们使用了StyleCI (来确保新代码能够匹配项目其余代码的编写风格) 以及 SensioLabsInsight (来实施自动化的代码质量分析)。这三个服务作为检查器而被配置,每一次Pull Request都必须在合并它之前通过。

当一个Pull Request被合并,Continuous Delivery进程启动 (参考 配置文件):

  1. 使用一个Circle CI environment variable(环境变量)来完成对Google Cloud的验证。
  2. 对生产环境构建Javascript文件。
  3. 构建项目的三个Docker镜像 (app, mails worker, users lists worker)。
  4. 推送构建好的镜像到Google Container Registry。
  5. 使用 kubectl 命令行工具来更新staging server (一种滚动更新)。

唯一手动操作的进程 (刻意的) 是SQL迁移。就算能够自动,我们仍倾向于在应用之前,谨慎审查那些迁移,以避免生产环境下的严重错误。

前端

程序的前端不遵循单页程序的模式。实际上,我们希望使用尽可能少的Javascript以改善性能,并依赖于浏览器的原生功能。

React + Webpack

程序的JavaScript使用了React并通过Webpack编译得以实现。我们不使用Redux - 甚至没有 React-Router - 只是纯React代码,同时我们只在页面中的特定容器中加载组件,而不是用它们来构建整页。这十分有用,有两个原因:

  • HTML内容在React被加载之前完全渲染完毕,然后React根据需要调整页面内容。这令程序在没有Javascript时可用,甚至页面在网速很慢的条件下加载时。此一技巧被称为 "progressive enhancement",它戏剧化地提升了可感知的性能。
  • 我们使用Webpack 2时用到了tree shaking以及chunks loading,以便每个页面用到的组件仅在必要时加载,因而不会令最小化的程序代码变大。

这个技巧要求我们按以下方式组织前端代码:

  • 程序根目录有一个 front/ directory 存储了全部的SASS和JavaScript文件。
  • 一个很小的 kernel.js 文件加载JavaScript vendors,同时加载了程序代码。
  • 一个 app.js 文件加载程序不同的组件。
  • 在Twig模板中,我们为每一个页面加载所需组件 (例如, address autocomplete component

前端性能

前端性能常被忽视,但网络通常是你程序的最大瓶颈。在后台省下几个毫秒无伤大雅,但要是加载图片的时间节省3秒以上,则会改变(用户)对网站的认知。

图片是前端性能的主要问题。活动组织者希望发布大量图片,但用户希望快速加载页面。解决方案是,使用强力压缩算法并配合其他一些技巧。

首先,我们把图片内容存到了Google Cloud Storage中,图片的metadata则被存入数据库 (使用的是 Doctrine entity called Media)。这就让我们,比如,能够知道图片尺寸而毋须加载它。这可以让我们设计出一个“在图片加载时不卡顿”的页面。

再有,我们对Media entity date使用了 Glide library:

  • 调整关联图片的大小: 例如,显示在总首页的一个小格子区块中的图片,较之显示在主力文章中的图片,可以做到更小、分辨率更低。
  • 更好的图片压缩: 所有图片都作为progressive jpeg被压缩,quality是70%。这种改变,较之png等其他格式,极大地改善了加载时间。

把Glide整合到Symfony中,是通过 AssetController 中的一个简单方法而实现的,在这个方法中我们使用了签名和缓存,以便减轻对此方法的DDOS攻击。

第三,我们懒加载了滚动条下方的全部图片,这包含了三个步骤:

  1. 尽可能快地加载滚动条上方的全部元素,同时等待下方的。
  2. 对于滚动条下方的图片加载其最低分辨率版本 (由Glide生成) ,然后通过本地Javascript代码来对其应用一个gaussian blur filter。
  3. 当高质图片加载完成后,替换这些模糊滤镜的占位符。

我们实现了一个“应用程序”级别的 Javascript listener,以便在全站任何页面应用这种行为。

表单

项目包括了一些有意思的表单。首先就是 网站注册表单: 根据国别和邮编,“所在城市”字段会根据输入的文本适配到已经预加载的下拉列表。

技术角度讲有两个字段: “cityName” 和 “city” (后者是根据法国规定而产生的城市代号)。表单组件一如既往地从请求中加载这两个字段。

而在视图层,只有cityName字段被初始化显示。如果国别选择了法国,我们使用了一些JavaScript代码来显示城市的下拉列表。 这段JavaScript代码同时监听了邮政编码字段的change事件,再制造一个AJAX请求来获取相关城市的下拉清单。服务器端,如果所选的城市是法国,我们需要被提供一个城市代号,否则我们就使用城市名称字段本身。

这个技巧是本文前面提到的 progressive enhancement 渐进式强化的一个很好的例子。JavaScript代码,跟其他内容一样,只是令事情变好的一个helper,但它对于功能实现来讲并非绝对严苛。

由于这些地址相关字段被使用在程序的很多地方,我们把它抽象成一个 AddressType form type 并与 an address Javascript component(地址专用JS组件)进行了关联。

其他有意思的表单 则是让你发送邮件给某个人,以说服他们给候选人投票。这是个multi-step多步表单,先问询该人的一些问题 (性别/年龄/工作类型/兴趣爱好等等),然后会生成可以当作邮件来发送出去的定制内容。

技术上,这个表单结合了高动态的Symfony表单组件连同Workflow工作流组件,可谓将此二者进行整合的绝佳实例。具体实现过程是基于一个由名为 InvitationProcessor 的model类,它是由multi-step 以及 dynamic form type 动态表单类型联合加载进来的,表单的内容被存到了session中。Workflow用于确保model对象的有效性,对于model的每一个state,定义了允许哪一个transition: 参考 InvitationProcessorHandlerworkflows.yml config

检索引擎

所用的检索引擎超级快并且提供了实时结果,由 Algolia 驱动。同程序entity (文章/页面/委员会/活动等等) 索引的整合,是由 AlgoliaSearchBundle 完成的。

这个bundle特别有用。我们只是向Doctrine entities添加了一些annotation,随后,用于搜索的索引就随着每个entity新条目的创建而自动地更新了。技术上来说,这个bundle对Doctrine events进行了监听,因此你毋须做任何事即可保持搜索内容始终得到更新。

安全性

如同其他的高知名度网站,我们是一些“由强力组织”协调和展开的攻击目标。多数攻击是暴力类型,目的是让网站挂掉而非渗透进来。

整个竞选活动中,网站被 DDoS attacks 锁定了八次,五次发生在最后两个星期。他们没有对Symfony造成冲击,因为已经转移到了Cloudflare,而且我们基于Kubernetes完成了定制的规模化。

首先,我们苦于三次基于WordPress pingbacks的攻击。攻击者使用了成千上万的WordPress网站肉鸡来向我们的网站发送pingback请求,nginx配置中的 一些检查 抵消了这种攻击。

另外一些攻击手法高超,需要同时利用Cloudflare和Varnish来抵消之。使用Cloudfare对assets进行经缓存是如此高效,以至我们认为并没有必要再使用反向代理。反向代理在DDoS攻击中被证明是必要的: 竞选活动的最后几天,攻击猛烈 (高达每秒300,000次请求),我们不得不关闭了会员系统并开启了Cloudfare的 "Cache Everything" 旗标。

要防范安全攻击,你什么也做不了,但你可以遵循着Symfony的最佳实践来把攻击转移开。顺带一提,Symfony是开源世界中罕有的拥有 实施公共安全审计 的项目。

开源

en-marche.fr网络平台 及其关联项目已经被开源了,就在 @EnMarche GitHub account 上。我们没有过多地渲染这个想法,因为开源二字对非技术人士来说过于复杂而难以解释清楚。然而,我们还是从那些发现此项目的人中收获了一些贡献,我们对这个项目被开源而感到高兴。

同时我们也在考虑,通过贡献一些“为竞选项目开发出来的元素”来回馈Symfony。UnitedNationsCountryType form type 可能对某些项目有用。我们也开发了一个整合了Mailjet服务的版本,可以作为Symfony bundle提供。