我是Kurio(来自印度尼西亚的一款新闻聚合器)的软件工程师。Kurio是一款聚合器应用程序,我们的主要工作是:收集发布合作伙伴网站上的新闻或文章,并通过我们的应用程序将其提供给用户。
与其他新闻聚合器一样,我们为用户提供了多种新闻内容,例如按我们的top_stories逻辑进行排序的新闻、按照趋势进行分类的新闻以及来自特定发布商的新闻。
Feed的构建过程由我们的Feed服务负责处理。
这个服务是Kurio的三大主要项目之一,之前的版本已经运行了很长一段时间。因此,它变得非常复杂,有时也会难以理解。这也使得添加新功能变得非常困难。因此,我们决定重建我们的Feed服务。希望通过这个新版本的Feed服务,我们可以轻松添加新功能或者使其更易于维护。
在这个新项目中,我们创建了新的架构,并混合了旧架构,具备了动态和灵活性。我们知道,新闻源可以是任意类型的对象,比如文章、视频、音频等等。使用Go语言实现这些真的很有挑战性,因为Go语言是一种静态类型的编程语言,它没有Java或其他编程语言的泛型类型。
首先我们需要了解以前的系统是如何工作的。从编译、测试和部署开始,直到收到用户请求,我们需要知道整个过程的工作原理。
因为这是一个核心的服务,而我刚刚来这里一年,我真的不知道它是如何运作的,尤其是多年来整个系统添加了很多额外的功能和补丁,很难通过阅读代码来了解它。所以,我们需要了解流程和规则,然后基于这些流程和规则构建新的流程和规则。
例如,当用户打开应用程序时,会得到由这项服务提供的top_stories新闻源。或者是一些规则,例如:在top_stories新闻源中向用户显示的内容是有限制的。或者类似于:不要向用户展示他们不关注的主题,或者根据用户的属性(性别、年龄等)显示新闻。
列出这些规则和流程是一件简单的事情,难就难在如何将其转换为代码。一般来说,我们的流程非常简单,如下所示。
基本上主要是两个大功能,获取个性化新闻和获取默认新闻。最难的是获取个性化新闻,因为我们必须将它与个性化引擎相结合。此外,我们必须遵循一些与上面提到的个性化内容相关的规则(提取用户兴趣和属性,然后根据用户的兴趣构建新闻源)。
我们之所以要重构这个服务,是因为当我们要添加新功能时,之前系统的代码架构无法很好地扩展。如果要在未来开发新功能会非常痛苦,因为我们不得不重构很多东西。
所以我们真正需要的是修复架构。设计一个新的架构真的很难。我们需要问自己很多问题,比如:“这样做会怎样?”、“为什么要这样?”、“为什么不是这样?”我们希望新架构能够解决“未来”的问题,并提供向后兼容性。为此,我们进行了大约一个月的讨论,针对每个大功能进行了技术栈和流程方面的讨论。
最终,我们决定尝试一些函数式的开发方式。我们放弃了之前使用的代码架构,发明了一种新的代码架构,带有函数式编程(使用高阶函数模式)的味道,但又不像Lisp或Clojure那么动态。
因此,在我们的代码中可以找到很多HOF(高阶函数)模式,如下所示:
func something(params, func(params)) (func(params)){}
但因为我们使用的是Go语言,一种静态类型的编程语言,所以当创建了很多函数时就会有很多痛点,必须进行大量的类型检查和转换,而这耗费了大量时间。
因此,我们意识到Go语言不适合用来解决我们的问题,但在我们这10个后端工程师当中,只有一个人了解Clojure(函数式编程),而学习新的编程语言就意味着我们需要额外的时间。经过长时间的讨论,我们决定继续使用Go语言,不仅是因为我们所有的后端工程师都很了解Go语言,也是因为Go语言已经在很多微服务中得到验证。
在将流程和设计转换为代码时,我意识到我们必须对基础有一个真正的了解。一开始,我并没有真正理解高阶函数的工作原理。在阅读代码时感到很困惑,怎么总是一个函数接收一个函数作为参数然后再返回一个函数呢?不过要感谢谷歌,我现在终于明白了。
我们还需要了解Go语言本身的基础知识,比如使用指针作为函数接收器、DateTime的基础知识,以及很多其他基础的东西。如果我们对这些东西不了解,只会增加完成这个项目的时间。
因此,在开发这个服务时,我们的第一个目标是确保至少可以运行它。我们没有去考虑性能问题,并试图忽略任何有关优化的事情,例如使用Go例程。
在开发完代码后,我们就可以编译并运行它,所有请求都能被正常处理,响应也很正常。当然,初始版本速度非常慢。与之前的系统相比,它慢了十倍。以前的系统在使用staging服务器时单个API请求大约需要500毫秒,而新版本需要50000毫秒(约50秒)或更久。
优化代码也是我们最重要的任务之一。为了优化我们的代码,我们遵循了以下步骤:
构建服务时,我们的规则是只在确实需要使用缓存的情况下使用缓存。缓存就像一种药物,它会让我们上瘾,因为当我们的系统看起来很慢时,会把缓存看成是解决问题的灵丹妙药。通常,在开发大型并发项目时沉迷于使用“缓存”的人,首先想到的是“缓存”,而不是先考虑优化(基准测试、分析)功能(逻辑/算法)。
对于我们的情况,我们通过两种方式来使用缓存:
通过这种优化,我们至少可以像在以前的系统中那样改进新系统的性能(staging服务器的响应时间约为400毫秒,生产服务器的响应时间约为180毫秒)。
基于语义版本控制,在不添加新特性和不破坏API的情况下进行重新构建就不算是一个新的版本。基本上,在这个新重建的系统中,我们的目标是改变架构,而不是API规范。因此,无论我们在系统中进行做出哪些变更,都不能更改API。因为即使是非常微小的变化也会影响到所有相关的服务。
为了让它成为一个新版本,我们对错误响应消息正文进行了一些修改。
原始错误响应消息正文:
{ \u0026quot;error\u0026quot;: \u0026quot;Error Message\u0026quot;}
新版本的错误响应消息正文:
{ \u0026quot;error\u0026quot;: { \u0026quot;message\u0026quot;: \u0026quot;Error Message\u0026quot;, \u0026quot;errors\u0026quot;: [ // any stack-trace errors ] }}
因为进行了这些变更,我们还需要处理其他使用了我们API服务的相关服务。所幸的是,只有两种服务使用了我们的API服务,所以我们只需要更新两个应用程序:仪表盘应用程序和移动网关API。此外,因为只有响应错误发生了重大变更,所以只需要修改应用程序的一小部分即可。
在重新构建这个服务时,我们至少进行了三次测试,然后才发布到生产环境中:单元测试、集成测试和负载测试。
在所有这些类型的测试中,单元测试是最小的测试。有些人似乎低估了单元测试的重要性,因为它只是一个单元,一个小功能。但是,在重建这个新服务时,我体会到了单元测试的重要性。
在Sprint开始时,我们忽略了单元测试,因为我们希望专注在代码架构的设计上。所以我们开发了一些没有任何测试的功能。我们这样做是因为我们仍然在构建一些实验性的代码架构,为了避免进行不必要的单元测试重构,我们在这个时候没有创建任何单元测试。
但是,在完成代码架构设计之后,我们忘了为在Sprint开始时创建的功能添加单元测试。直到我们将它部署到staging服务器并与另一个真实服务进行了集成测试。我们在应用程序中发现了很多错误。然后我们查看了源代码,发现我们的功能有很多条件都没有覆盖到。
知道了这个问题后,我们意识到我们还没有测试过这个功能。它还没有通过单元测试。如果我们从一开始就进行单元测试,那么修复这个问题并重新部署它就不需要做额外的工作。在进行单元测试时,我们可以考虑很多不同情况,并在部署应用程序之前修复它们。
虽然我们做的是幕后工作,并且对我们的用户没有可见的影响,但我们确实学到了很多东西。我学到了很多关于如何从头开始构建高并发系统的知识。完成这项任务后,我知道了为什么当我们在面试后端开发职位时,总会被问及逻辑和算法问题。这是因为在构建高并发系统时,性能是非常重要的方面,任何算法都会影响到系统的响应时间。
英文原文:https://dzone.com/articles/we-rebuilt-our-backend-feed-service-here-what-i-le