Bun中的Bootstage Angular Angular服务器端渲染
#angular #node #bunjs

我有一个想法使用BUN运行时(https://bun.sh)来运行Angular Server端渲染。我首先从头开始创建一个简单的“ Hello World”示例。之后,我尝试通过通过ng add @nguniversal/express-engine命令合并Express Engine来运行Express服务器。

让我们首先创建一个基本的独立应用,然后添加Express引擎:

$ ng new bun-universal --minimal --style scss --routing false --standalone
$ cd bun-universal
$ yarn ng add @nguniversal/express-engine

接下来,让我们执行示意图提供的命令以构建浏览器和服务器捆绑包:

$ yarn build:ssr

之后,我们将尝试使用BUN启动服务器:

$ bun dist/bun-universal/server/main.js

输出应显示Node Express server listening on http://localhost:4000。当我们在浏览器中导航到“ Localhost”时,我们预计将看到服务器端渲染的应用程序,其中Angular在运行NG NEW时会生成的内容。但是,我们实际观察到的是以下内容:

Bun default page

我花了大量时间来试图理解失败的确切性质。首先,我在根目录中创建了一个名为server.js的附加文件。该文件包含一个基本的独立Express服务器,我用来确定它是否会提供有效的响应:

// server.js

import path from 'node:path';
import express from 'express';

const app = express();
const distFolder = path.join(process.cwd(), 'dist/bun-universal/browser');

app.get('*.*', express.static(distFolder));

app.get('/', (req, res) => {
  res.sendFile(path.join(distFolder, 'index.html'));
});

app.listen(4200, () => {
  console.log(`Node Express server listening on http://localhost:4200`);
});

上面的代码作为静态文件服务器的功能,仅用请求的文件响应特定的URL。我首先导入服务器端渲染所需的角依赖关系。在此过程中,我观察到zone.js/node在某些情况下破坏了预期的行为。如果我们在server.js文件顶部包含以下行:

import 'zone.js/node';

当我们使用bun server.js再次运行服务器时,显然服务器永远不会响应。这表明由zone.js加载的某些补丁正在引起干扰。这些补丁似乎会影响在BUN运行时暴露的本机类,也会影响fs之类的内置模块。值得注意的是,Express依赖于setImmediatefs模块的内部用法(在send软件包中,用于流媒体文件)。有趣的是,如果我们禁用node_timers补丁,服务器将变得功能性:

globalThis.__Zone_disable_node_timers = true;

require('zone.js/node');

import path from 'node:path';
import express from 'express';

通过禁用node_timers补丁,zone.js库不知道任何计划的setTimeoutsetInterval任务。因此,Angular不会等待所有这些计划的任务被调用,从而导致序列化HTML的早期返回。我遇到的另一个问题涉及与承诺相关的补丁。在这种情况下,bun并不能将ZoneAwarePromise识别为Promise,并且无法等待承诺的解决方案。以下示例说明了此问题:

app.get('/', async (req, res) => {
  await new Promise(resolve => setTimeout(resolve, 2000));

  console.log('after await');

  res.sendFile(path.join(distFolder, 'index.html'));
});

使用此代码,服务器将再次返回默认的BUN页面,显示Fetch没有返回响应对象的消息。但是,在延迟2秒后,您仍然会观察到console.log。如果我们使用以下代码禁用承诺补丁:

globalThis.__Zone_disable_ZoneAwarePromise = true;

然后,我们将在2秒延迟后观察index.html的含量。

考虑到所有上述要点,我得出的结论是,我需要使用zone.js禁用服务器。这是因为zone.js应用的补丁与BUN运行时不兼容。

弥合差距

让我们从编辑server.ts文件开始。我们可以从Express.Strespation中删除maxAge选项。由于目前不需要缓存。此外,让我们从文件顶部删除zone.js/node的导入。

如果我们遵循以下步骤:构建应用程序并再次运行它,我们将遇到NG0908异常。此例外表明,Angular需要zone.js,因为NgZone构造函数依赖于它:

if (typeof Zone == 'undefined') {
    throw new RuntimeError(908 /* RuntimeErrorCode.MISSING_ZONEJS */, ngDevMode && `In this configuration Angular requires Zone.js`);
}
Zone.assertZonePatched();

要解决上述问题,我们需要用NoopNgZone替换NgZone注射者。让我们在src/app目录中编辑app.server.config.ts文件:

import {
  mergeApplicationConfig,
  ApplicationConfig,
  NgZone,
  ɵNoopNgZone,
} from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';

import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    { provide: NgZone, useClass: ɵNoopNgZone },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

再次构建应用程序,运行服务器并使用curl。您应该观察以下渲染的html:

First Bun result

使用上述设置,此方法仅对在渲染过程中不使用任何异步API的应用程序成功起作用。但是,这不是现实的情况,因为HTTP请求通常是在服务器端渲染期间提出的,通常是一次获取数据并将其保存到传输状态。

Angular等待直到完成渲染过程中的所有任务完成,然后将HTML序列归还给客户端:

await applicationRef.isStable.pipe((first((isStable: boolean) => isStable))).toPromise();

让我们将provideHttpClient()添加到app.config.ts文件中,并将以下代码合并到我们的AppComponent中:

export class AppComponent {
  title = 'bun-universal';

  constructor() {
    inject(HttpClient)
      .get('https://jsonplaceholder.typicode.com/todos/1')
      .subscribe(() => {
        this.title = 'bun-universal-v2';
      });
  }
}

如果我们构建了应用程序,再次运行服务器并执行curl请求,我们会观察到title保留bun-universal

<h1>Welcome to bun-universal!</h1>

这是因为该应用程序立即变得稳定,因为没有运行的微型和宏任务。

我们需要实施一种机制,才能等到渲染期间计划的所有HTTP请求均已完成。 Angular已经包括一个名为ɵInitialRenderPendingTasks的类,该类封装了行为主题hasPendingTasks。每当完成所有HTTP任务时,hasPendingTasks的值会更改。此类由@angular/common/http软件包中的HttpInterceptorHandler使用。启动HTTP请求时,Angular会增加待处理任务的计数。

我们还需要在ApplicationRef中实现自定义的isStable行为,因为默认实现依赖于ngZone.isStable。此外,必须等到该应用程序引导后才订阅hasPendingTasks。这种预防措施是必要的,因为在安排任何HTTP请求之前,hasPendingTasks可能会过早发射false

接下来,我们将创建AppBootstrapped类。请注意,我将所有内容都放在app.config.server.ts文件中:

@Injectable({ providedIn: 'root' })
export class AppBootstrapped extends BehaviorSubject<boolean> {
  constructor() {
    super(false);
  }
}

当应用程序引导时,上面的主题将呈现True(当调用ApplicationRef.bootstrap并调用从APP_BOOTSTRAP_LISTENER注入令牌解决的听众时)。

和扩展了原始ApplicationRef并提供自定义isStable实现的自定义类:

@Injectable()
export class NoopNgZoneApplicationRef extends ApplicationRef {
  override isStable: Observable<boolean>;

  constructor() {
    super();

    const pendingTasks = inject(ɵInitialRenderPendingTasks);

    this.isStable = inject(AppBootstrapped).pipe(
      filter(appBootstrapped => appBootstrapped),
      mergeMap(() => pendingTasks.hasPendingTasks),
      map(hasPendingTasks => !hasPendingTasks)
    );
  }
}

我们注入AppBootstrapped类,等待该应用程序被引导,然后将订阅切换为hasPendingTasks,将其与isStable保持一致的值。 app.server.config.ts的最终内容:

import {
  mergeApplicationConfig,
  ApplicationConfig,
  NgZone,
  ɵNoopNgZone,
  ApplicationRef,
  Injectable,
  inject,
  ɵInitialRenderPendingTasks,
  APP_BOOTSTRAP_LISTENER,
} from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { BehaviorSubject, Observable, filter, map, mergeMap } from 'rxjs';

import { appConfig } from './app.config';

@Injectable({ providedIn: 'root' })
export class AppBootstrapped extends BehaviorSubject<boolean> {
  constructor() {
    super(false);
  }
}

@Injectable()
export class NoopNgZoneApplicationRef extends ApplicationRef {
  override isStable: Observable<boolean>;

  constructor() {
    super();

    const pendingTasks = inject(ɵInitialRenderPendingTasks);

    this.isStable = inject(AppBootstrapped).pipe(
      filter(appBootstrapped => appBootstrapped),
      mergeMap(() => pendingTasks.hasPendingTasks),
      map(hasPendingTasks => !hasPendingTasks)
    );
  }
}

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    { provide: NgZone, useClass: ɵNoopNgZone },
    { provide: ApplicationRef, useClass: NoopNgZoneApplicationRef },
    {
      provide: APP_BOOTSTRAP_LISTENER,
      multi: true,
      useFactory: () => {
        const appBootstrapped = inject(AppBootstrapped);
        return () => appBootstrapped.next(true);
      },
    },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

最后一步是在更改标题时手动运行更改检测,因为ApplicationRef.tick方法没有自动触发器。重要的是要注意,在OnPush组件中,手动更改检测触发也是必要的。因此,可以在浏览器和服务器之间互换代码:

export class AppComponent {
  title = 'bun-universal';

  constructor() {
    const ref = inject(ChangeDetectorRef);

    inject(HttpClient)
      .get('https://jsonplaceholder.typicode.com/todos/1')
      .subscribe(() => {
        this.title = 'bun-universal-v2';
        ref.detectChanges();
      });
  }
}

现在,在构建和运行服务器后,再次执行卷曲请求:

Second Bun result

我们可以注意到<h1>Welcome to bun-universal-v2</h1>元素。

跟踪计时器

当前没有能力跟踪使用setTimeout安排的计时器,也没有实际的理由可以在代码在服务器端运行时安排计时器。该限制背后的主要理由是,任何计时器都可能在响应中引入延迟。在常规方法中,Angular将在响应之前等待应用程序稳定性(await appRef.isStable)。虽然可以用isPlatformBrowser包装所有计时器,但通常是对这些计时器安排的代码的控制有限。

考虑一个方案,有人订阅了一个值流,并使用debounceTime订阅流,该方案在内部使用asyncScheduler默认情况下。每次溪流发出值时,操作员都会重新安排内部计时器。

服务器端运行的代码通常与角度代码的同步或异步性质有关。为了坦率,浏览器中通常使用计时器和动画框架来增强UI性能并防止渲染过程中的潜在帧下降。但是,在服务器端,帧下降并不关心,因此没有理由安排计时器。如果在服务器端执行代码的情况

export class AppComponent {
  constructor() {
    const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

    source$
      .pipe(isBrowser ? debounceTime(1000) : identity, takeUntilDestroyed())
      .subscribe(() => {
        // ...
      });
  }
}

但是,正如我之前提到的,由于我们没有必要在代码中明确安排计时器,因此可能仍在服务器端使用的第三方库安排它们。

添加和删​​除任务

让我们考虑以下示例:我们lazy-load-load logus生成随机nonce的库(懒惰节点库库所需转移状态:

export class AppComponent implements AfterViewInit {
  title = 'bun-universal';

  private readonly _transferState = inject(TransferState);
  private readonly _isServer = isPlatformServer(inject(PLATFORM_ID));

  async ngAfterViewInit(): Promise<void> {
    if (this._isServer) {
      const { cryptoRandomStringAsync } = await import('crypto-random-string');

      const nonce = await cryptoRandomStringAsync({
        length: 20,
        type: 'base64',
      });

      this._transferState.set(CSP_NONCE_KEY, nonce);
    }
  }
}

如果我们运行服务器并执行curl命令,我们会注意到状态未序列化。在服务器端,导入等效于Promise.resolve().then(() => require(...))import Microtask安排在appRef.isStable.toPromise() Microtask之前。同样,cryptoRandomStringAsync也安排在isStable Microtask之前。但是,由于cryptoRandomStringAsync计划其他微型掩体,isStable MicroTask早些时候解决了。任何新安排的微型掩体都将添加到微型箱队列的末端。这就是为什么在传输状态设置值之前将HTML序列化的原因。

由于我们已经熟悉了InitialRenderPendingTasks类,因此我们可以通过通知Angular的功能受益于其功能,直到完成之前,仍有已待处理的任务:

export class AppComponent implements AfterViewInit {
  title = 'bun-universal';

  private readonly _transferState = inject(TransferState);
  private readonly _isServer = isPlatformServer(inject(PLATFORM_ID));
  private readonly _pendingTasks = inject(ɵInitialRenderPendingTasks);

  async ngAfterViewInit(): Promise<void> {
    if (this._isServer) {
      const taskId = this._pendingTasks.add();

      const { cryptoRandomStringAsync } = await import('crypto-random-string');

      const nonce = await cryptoRandomStringAsync({
        length: 20,
        type: 'base64',
      });

      this._transferState.set(CSP_NONCE_KEY, nonce);

      this._pendingTasks.remove(taskId);
    }
  }
}

基准测试节点和bun

bun的“ Hello World”服务器渲染React应用具有基准测试,指出它的速度比Deno快2倍,并且比节点快3倍。

我选择了创建Docker图像并在容器中本地运行它们。随后,我将利用autocannon工具在根端点上进行负载测试。

让我们从创建一个.dockerignore文件开始,以防止在构建过程中将不必要的文件夹复制到容器中:

.angular
dist
node_modules
.DS_Store

现在,让我们继续添加Dockerfile

FROM node:18-alpine AS build
WORKDIR /tmp
COPY . .
RUN yarn --pure-lockfile && yarn build:ssr

# FROM oven/bun
FROM node:18-alpine
WORKDIR /usr/src/app
COPY --from=build /tmp/dist ./dist
EXPOSE 4200
# CMD ["bun", "dist/bun-universal/server/main.js"]
CMD ["node", "dist/bun-universal/server/main.js"]

观察评论的FROMCMD命令。构建节点和BUN图像的说明非常相似,只有很小的差异。

我将通过交替这些命令来构建2张图像。为节点构建时,我将评论FROM oven/bun及其CMD。相反,在为bun构建时,我会发表评论FROM node:18-alpine及其CMD

$ docker build -t node-universal .
$ # Now comment `FROM` and `CMD` for Node and uncomment for Bun
$ docker build -t bun-universal .

现在让我们运行node-universal容器并使用autocannon

$ docker run -dp 4200:4200 -e 'PORT=4200' node-universal
$ autocannon -c 100 -d 10 http://localhost:4200
$ docker stop containerId

Node benchmark result

让我们对bun做同样的事情:

$ docker run -dp 4200:4200 -e 'PORT=4200' bun-universal
$ autocannon -c 100 -d 10 http://localhost:4200
$ docker stop containerId

Bun benchmark result

因此,是3K请求节点和BUN的5K请求。请注意,这些结果实际上可能在操作系统和硬件之间有所不同。我在Mac上进行了这些测试。在安装Ubuntu的另一台计算机上,我观察到了11K的节点请求和14K BUN请求。即使对于一个简单的“ Hello World”应用程序,这些结果也可能有些不稳定,而且我无法确定在现实生活中的bun是否会更快。


代码可以在https://github.com/arturovt/bun-angular-universal中找到。