让我们去吧。随即一个小的免责声明:这篇文章是根据我在Ya Subbotnik Pro上为前端开发人员的演讲而写的。如果您是后端开发人员,则可能不会自己发现任何新东西。在这里,我将尝试总结我在大型企业中的前端体验,解释为什么以及如何使用Node.js。
让我们定义在本文中将被视为前端的内容。让我们撇开关于任务的争论,集中精力在本质上。
前端是应用程序中负责显示的部分。可以不同:浏览器,台式机,移动设备。但是始终有一个重要功能-前端需要数据。如果没有提供此数据的后端,它将毫无用处。这是一个相当清晰的边界。后端知道如何进入数据库,将业务规则应用于接收到的数据,并将结果提供给前端,前端将接受数据,对其进行模板化并赋予用户美丽的外观。
我们可以说,从概念上讲,前端需要前端来接收和保存数据。示例:具有客户端-服务器体系结构的典型现代站点。浏览器中的客户端(称其为瘦语言将不再关闭)敲响了运行后端的服务器。当然,到处都有例外。有些复杂的浏览器应用程序不需要服务器(我们不会考虑这种情况),并且需要在服务器上执行前端-称为服务器端渲染或SSR。让我们开始吧,因为这是最简单,最容易理解的情况。
固态继电器
后端的理想世界是这样的:带有数据的HTTP请求到达应用程序的输入,在输出处,我们以方便的格式响应新数据。例如JSON。 HTTP API易于测试并了解如何开发。但是,生活需要调整:有时仅靠API是不够的。
服务器应使用现成的HTML进行响应,以将其提供给搜索引擎抓取工具,使用元标记渲染预览以插入社交网络,或者更重要的是,在功能较弱的设备上加快响应速度。就像在古代我们用PHP开发Web 2.0一样。
一切都是熟悉的,已经描述了很长时间,但是客户端已经发生了变化-势在必行的客户端模板引擎已经出现。在现代网络中,JSX占据了主导地位,其优缺点可以被长期讨论,但是不能否认一件事-在服务器渲染中,没有JavaScript代码是无法做的。
原来,您需要通过后端开发来实现SSR:
- 责任领域是混合的。后端程序员开始负责渲染。
- 语言是混合的。后端程序员开始使用JavaScript。
解决方法是将SSR与后端分开。在最简单的情况下,我们使用一个JavaScript运行时,在上面放上一个可以与我们所需的JavaScript模板引擎一起工作的自写解决方案或框架(Next,Nuxt等),并通过它进行通信。现代世界中一种熟悉的模式。
因此,我们已经允许前端开发人员使用该服务器。让我们继续讨论一个更重要的问题。
接收资料
一种流行的解决方案是创建通用API。 API网关最常担当此角色,该网关能够轮询各种微服务。但是,这里也会出现问题。
首先,团队和责任范围的问题。许多团队开发了一个大型的现代应用程序。每个团队都专注于自己的业务领域,在后端拥有自己的微服务(甚至是多个),并在客户端拥有自己的显示。我们不会涉及微观前沿和模块化的问题,这是一个单独的复杂主题。假设客户端视图是完全独立的,并且是一个大型站点内的mini-SPA(单页应用程序)。
每个团队都有前端和后端开发人员。每个人都在处理自己的应用程序。 API网关可能是绊脚石。谁负责?谁将添加新的端点?一个专门的API超级团队会一直忙于为项目中的其他所有人解决问题吗?错误的代价是什么?该网关的崩溃将使整个系统瘫痪。
其次,数据冗余/不足的问题。让我们看看当两个不同的前端使用相同的通用API时会发生什么。
这两个前端非常不同。他们需要不同的数据集,它们具有不同的发布周期。移动前端版本的可变性最大,因此我们被迫设计具有最大向后兼容性的API。 Web客户端的可变性很低,实际上,我们只需要支持一个以前的版本,以减少发布时的错误数量。但是,即使“通用” API只为Web客户端提供服务,我们仍然面临冗余或数据不足的问题。
每个映射都需要一组单独的数据,希望通过一个最佳查询来提取数据。
在这种情况下,通用API对我们不起作用,我们将不得不分离接口。这意味着您需要为每个应用程序自己的API网关前端。单词“每个”在这里表示在其自己的数据集上运行的唯一映射。
我们可以将此类API的创建委托给后端开发人员,该后端开发人员必须与前端一起工作并实现他的愿望,或者,将这种API的实现交给前端团队,这将更加有趣并且在许多方面更加高效。这将消除由于SSR实施而引起的麻烦:您不再需要安装敲打API的层,所有内容都将集成到一个服务器应用程序中。另外,通过控制SSR,我们可以在呈现时将所有必要的主要数据放在页面上,而无需向服务器提出其他请求。
这种体系结构称为后端后端或BFF。这个想法很简单:服务器上出现一个新应用程序,用于侦听客户端请求,轮询后端并返回最佳响应。当然,此应用程序由前端开发人员控制。
后端有多个服务器?没问题!
无论后端开发偏爱哪种通信协议,我们都可以使用任何便捷的方式与Web客户端进行通信。 REST,RPC,GraphQL-我们选择自己。
但是GraphQL本身不是解决单个查询中获取数据问题的解决方案吗?也许您不需要隔离任何中间服务?
不幸的是,如果没有与承担开发高效数据库查询任务的后端开发人员密切合作,使用GraphQL进行高效工作是不可能的。通过选择这样的解决方案,我们将再次失去对数据的控制权,并返回到开始的地方。
当然,这是可能的,但不是很有趣(对于前端)
好吧,让我们实现BFF。当然,在Node.js中。为什么?我们需要在客户端和服务器上使用一种语言来重用前端开发人员的经验和JavaScript来使用模板。那其他运行时环境呢?
GraalVM和其他奇特的解决方案在性能上不如V8,而且过于具体。 Deno仍是实验,并未在生产中使用。
一会儿。 Node.js是实现API网关的出奇的好解决方案。 Node体系结构允许将单线程JavaScript解释器与libuv结合使用,libuv是一个异步I / O库,该库又使用线程池。
JavaScript方面的长时间计算会影响系统性能。您可以解决此问题:在单独的工作程序中运行它们,或将它们带入本机二进制模块级别。
但是在基本情况下,Node.js不适合用于CPU密集型操作,同时,它与异步I / O配合使用时性能很好。也就是说,我们获得了一个系统,该系统始终可以快速响应用户,无论后端的繁忙程度。您可以通过立即通知用户等待操作结束来处理这种情况。
哪里存储业务逻辑
我们的系统现在包含三个主要部分:后端,前端和两者之间的BFF。一个合理的(对于架构师)问题出现了:在哪里保留业务逻辑?
当然,架构师不希望在系统的所有层上涂抹业务规则;应该有一个真实的来源。而那个来源就是后端。如果不在系统中最靠近数据的部分中,则还可以在哪里存储高级策略?
但是实际上,这并不总是有效的。例如,出现了一个业务问题,可以在BFF级别上高效,快速地实施。完美的系统设计很棒,但是时间就是金钱。有时您不得不牺牲架构的整洁度,并且各层开始泄漏。
通过放弃BFF来支持“完整的” Node.js后端,我们能否获得完美的体系结构?在这种情况下,似乎没有泄漏。
不是事实。如果将业务规则传输到服务器,将会影响接口的响应能力。您可以忍受到最后,但是很可能您将无法完全避免。应用程序级逻辑也将渗透到客户端:在现代SPA中,即使在存在BFF的情况下,它也会涂抹在客户端和服务器之间。
无论我们多么努力,业务逻辑都会渗透到Node.js上的API网关。让我们修正这个结论,然后进行最美味的实现!
大泥球
近年来,Node.js应用程序最受欢迎的解决方案是Express。经过验证,但级别太低,无法提供良好的架构方法。主要模式是中间件。Express中的典型应用程序像一大堆泥巴(不是名称调用和antipattern)。
const express = require('express');
const app = express();
const {createReadStream} = require('fs');
const path = require('path');
const Joi = require('joi');
app.use(express.json());
const schema = {id: Joi.number().required() };
app.get('/example/:id', (req, res) => {
const result = Joi.validate(req.params, schema);
if (result.error) {
res.status(400).send(result.error.toString()).end();
return;
}
const stream = createReadStream( path.join('..', path.sep, `example${req.params.id}.js`));
stream
.on('open', () => {stream.pipe(res)})
.on('error', (error) => {res.end(error.toString())})
});
所有的层混合在一起,在一个文件中有一个控制器,那里有所有的东西:基础架构逻辑,验证,业务逻辑。使用它很痛苦,我不想维护这样的代码。我们可以在Node.js中编写企业级代码吗?
这需要易于维护和开发的代码库。换句话说,您需要架构。
Node.js应用程序架构(最终)
“软件体系结构的目标是减少构建和维护系统所需的人力。”
罗伯特“叔叔鲍勃”马丁
体系结构由两个重要的部分组成:层及其之间的连接。我们必须将应用程序划分为多个层,防止彼此泄漏,正确组织层的层次结构及其之间的连接。
层数
如何将我的应用程序分成几层?有一个经典的三层方法:数据,逻辑,表示。
现在认为这种方法已经过时了。问题在于数据是基础,这意味着应用程序的设计取决于数据在数据库中的呈现方式,而不取决于它们所参与的业务流程。
一种更现代的方法假设应用程序具有专用的领域层,该领域层可与业务逻辑一起工作,并以代码形式表示实际业务流程。但是,如果我们转向Eric Evans域驱动设计的经典作品,则会发现以下应用程序层方案:
怎么了 似乎使用DDD设计的应用程序的基础应该是一个域-高级策略,最重要和最有价值的逻辑。但是,在此层之下是整个基础结构:数据访问层(DAL),日志记录,监视等。也就是说,级别较低且重要性较低的策略。
基础结构是应用程序的核心,而记录器的陈旧替换可能会导致所有业务逻辑的动摇。
如果我们再次转向Robert Martin,我们会在《干净的体系结构》一书中发现他假设应用程序中的不同层层次结构以领域为中心。
因此,所有四个层都应以不同的方式排列:
我们选择了这些层并定义了它们的层次结构。现在让我们继续进行连接。
连接数
让我们回到带有用户逻辑调用的示例。如何摆脱对基础架构的直接依赖以确保正确的层次结构?有一种简单而众所周知的方式来逆转依赖关系-接口。
现在,高级UserEntity不再依赖于低级Logger。相反,它规定了必须执行的合同才能将Logger包含在系统中。在这种情况下,替换记录器归结为连接遵守相同合同的新实现。一个重要的问题是如何连接它?
import {Logger} from ‘../core/logger’;
class UserEntity {
private _logger: Logger;
constructor() {
this._logger = new Logger();
}
...
}
...
const UserEntity = new UserEntity();
各层是牢固连接的。文件结构和实现紧密相关。我们需要依赖反转,这将使用依赖注入来完成。
export class UserEntity {
constructor(private _logger: ILogger) { }
...
}
...
const logger = new Logger();
const UserEntity = new UserEntity(logger);
现在,“域” UserEntity对记录器的实现一无所知。它提供了一个合同,并期望实现符合该合同。
当然,手动生成基础结构实体的实例并不是最令人愉快的事情。我们需要一个根文件,其中准备了所有内容,我们将不得不以某种方式将记录器的实例拖到整个应用程序中(拥有一个记录器而不是创建多个记录器是有益的)。累人。这就是IoC容器发挥作用的地方,可以接管这项bollerplate的工作。
使用容器会是什么样子?例如,像这样:
export class UserEntity {
constructor(@Inject(LOGGER) private readonly _logger: ILogger){ }
}
这里发生了什么?我们使用了修饰器的魔力,并编写了指令:“在创建UserEntity实例时,将位于IOGER容器中LOGGER令牌下的实体实例注入到_logger的私有字段中。它应该符合ILogger接口。” 然后,IoC容器将自己完成所有操作。
我们选择了图层,并决定了如何解开它们。现在该选择框架了。
框架和架构
问题很简单:通过将Express保留为现代框架,我们将获得一个好的架构吗?让我们来看看Nest:
- 用TypeScript编写
- 建立在Express / Fastify之上,在中间件级别具有兼容性,
- 声明逻辑的模块化,
- 提供一个IoC容器。
似乎这里有我们需要的一切!他们还离开了将应用程序作为中间件链的概念。但是好的架构呢?
Nest中的依赖注入
让我们尝试按照说明进行操作。由于在Nest中,术语Entity通常应用于ORM,因此将UserEntity重命名为UserService。记录器由框架提供,因此我们将注入抽象的FooService。
import {FooService} from ‘../services/foo.service’;
@Injectable()
export class UserService {
constructor(
private readonly _fooService: FooService
){ }
}
而且...似乎我们退了一步!有注入,但没有反转,依赖关系是
针对实现而非抽象的。
让我们尝试修复它。选项一:
@Injectable()
export class UserService {
constructor(
private _fooService: AbstractFooService
){ } }
我们在附近的某个地方描述并导出此抽象服务:
export {AbstractFooService};
FooService现在使用AbstractFooService。因此,我们在IoC中手动注册。
{ provide: AbstractFooService, useClass: FooService }
第二种选择。让我们尝试前面介绍的带有接口的方法。由于JavaScript中没有接口,因此将不再可能在运行时使用反射将所需实体从IoC中拉出。我们必须明确说明我们需要什么。我们将为此使用@ Inject装饰器。
@Injectable()
export class UserService {
constructor(
@Inject(FOO_SERVICE) private readonly _fooService: IFooService
){ } }
并通过令牌注册:
{ provide: FOO_SERVICE, useClass: FooService }
我们已经赢得了框架!但是要花多少钱呢?我们已经关闭了很多糖。这令人怀疑,建议您不要将整个应用程序捆绑到框架中。如果我还没有说服您,那么还有其他问题。
例外情况
Nest会闪烁,但有异常。此外,他建议使用异常抛出来描述应用程序行为的逻辑。
就建筑而言,这里一切都还好吗?让我们再次转向灯具:
“如果错误是预期的行为,那么您不应使用异常。”例外就是例外。在编写业务逻辑时,我们必须避免引发异常。如果仅出于JavaScript和TypeScript都不保证将处理异常的原因。此外,它混淆了执行流程,我们以GOTO风格开始编程,这意味着在检查代码的行为时,读者将不得不跳过整个程序。
马丁·福勒
有一条简单的经验法则可以帮助您了解使用异常是否合法:
“如果删除所有异常处理程序,代码将起作用吗?” 如果答案是否定的,那么也许在非例外情况下使用例外。”在业务逻辑中可以避免这种情况吗?是! 必须尽量减少引发异常,并方便地返回复杂操作的结果,请使用Either monad,它提供处于成功或错误状态的容器(这个概念非常接近Promise)。
实用程序员
const successResult = Result.ok(false);
const failResult = Result.fail(new ConnectionError())
不幸的是,在Nest提供的实体内部,我们经常不能采取其他行动-我们必须抛出异常。框架就是这样工作的,这是一个非常令人讨厌的功能。再次出现问题:也许您不应该使用框架来刷新应用程序?也许可以将框架和业务逻辑分为不同的体系结构层?
让我们检查。
嵌套实体和建筑层
严酷的生活真理:我们用Nest编写的所有内容都可以堆叠成一层。这是应用程序层。
我们不想让该框架更深入地进入业务逻辑,以使它不会因其异常,装饰器和IoC容器而发展为该逻辑。该框架的作者将介绍使用它的糖来编写业务逻辑有多么伟大,但是他们的任务是永远将您与自己联系在一起。请记住,框架只是一种方便地组织应用程序级逻辑,将基础结构和UI连接到它的方法。
“框架就是细节。”
罗伯特“叔叔鲍勃”马丁
最好将应用程序设计为构造函数,以便在其中轻松替换组件。这种实现方式的一个示例是六边形体系结构(端口和适配器体系结构)。这个想法很有趣:具有所有业务逻辑的域核心提供了用于与外界通信的端口。所需的一切都通过适配器从外部连接。
使用Nest作为框架在Node.js中实现这样的架构是否现实?相当。我举了一个例子,如果您有兴趣的话,可以在这里找到。
总结一下
- Node.js非常适合BFF。你可以和她住在一起。
- 没有现成的解决方案。
- 框架并不重要。
- 如果您的体系结构变得太复杂,如果您遇到打字问题,则可能选择了错误的工具。
我推荐这些书:
- 罗伯特·马丁(Robert Martin),“清洁建筑”,
- Vaughn Vernon, Domain-Driven Design Distilled,
- Khalil Stemmler, khalilstemmler.com,
- Martin Fowler, martinfowler.com/architecture.