Angular 2+ - 动态更改应用程序的基本路径

Angular 2+ - Dynamically change base path of app

简介

我知道这个问题已经以多种不同的形式提出。但是我已经阅读了所有 post 和我能找到的有关此事的文章,但无法用我们面临的先决条件解决我的问题。因此,我想先描述我们的问题并给出我们的动机,然后描述我们已经尝试或正在使​​用的不同方法,即使它们并不令人满意。

Problem/Motiviation

我们的设置可以说是有些复杂。我们的 Angular 应用程序部署在多个 Kubernetes 集群上,并从不同的路径提供服务。

集群本身在代理后面,Kubernetes 本身添加了一个名为 Ingress 的代理。因此,我们的常见设置可能如下所示:

本地

http://localhost:4200/

本地集群

https://kubernetes.local/angular-app

发展

https://ourcompany.com/proxy-dev/kubernetes/angular-app

分期

https://ourcompany.com/proxy-staging/kubernetes/angular-app

客户

https://kubernetes.customer.com/angular-app

始终为应用程序提供不同的基本路径。由于这是 Kubernetes,我们的应用程序部署了 Docker-containers。

传统上,人们会使用 ng build 转译 Angular 应用程序,然后使用例如一个 Docker 文件,如下所示:

FROM nginx:1.17.10-alpine

EXPOSE 80

COPY conf/nginx.conf /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*
COPY dist/ /usr/share/nginx/html/

要告诉 CLI 如何设置 base-href 以及从何处提供资源,可以使用 Angular CLI 的 --base-href--deploy-url 选项。

不幸的是,这意味着我们需要为每个要部署应用程序的环境构建一个 Docker-容器。

此外,我们必须在这个预构建过程中小心设置其他环境变量,例如在每个部署环境的 environments[.prod].ts 中。

当前解决方案

我们当前的解决方案包括在部署期间构建应用程序。这是通过使用不同的 Docker-container 来实现的,它被用作所谓的 initContainer,在 nginx-container 启动之前 运行s。

此文件的 Docker 文件如下所示:

FROM node:13.10.1-alpine

# set working directory
WORKDIR /app

# add `/app/node_modules/.bin` to $PATH
ENV PATH /app/node_modules/.bin:$PATH

# install and cache app dependencies
COPY package*.json ./

RUN npm install

COPY / /app/

ENV PROTOCOL http
ENV HOST localhost
ENV CONTEXT_PATH /
ENV PORT 8080
ENV ENDPOINT localhost/endpoint
VOLUME /app/dist

# prebuild ES5 modules
RUN ng build --prod --output-path=build
CMD \
  sed -i -E "s@(endpoint: ['|\"])[^'\"]+@${ENDPOINT}@g" src/environments/environment.prod.ts \
  && \
  ng build \
  --prod \
  --base-href=${PROTOCOL}://${HOST}:${PORT}${CONTEXT_PATH} \
  --deploy-url=${PROTOCOL}://${HOST}:${PORT}${CONTEXT_PATH} \
  --output-path=build \
  && \
  rm -rf /app/dist/* && \
  mv -v build/* /app/dist/

将此容器合并到 Kubernetes 部署中作为 initContainer 允许使用正确的 base-href、deployment-url 构建应用程序,并且特定变量是根据部署此应用程序的环境进行替换。

除了它产生的一些问题外,这种方法非常有效:

  1. 一次部署需要几分钟
    每次重新部署应用程序时,initContainer 都需要 运行。当然,这总是 运行s ng build 命令。为防止每次容器已经 运行s 之前的 RUN 指令中的 ng build 命令缓存这些时,它都会构建 ES 模块。
    尽管如此,用于差载和 Terser 等的建筑物再次 运行ning 需要几分钟才能完成。
    当存在水平 pod 自动缩放时,额外的 pods 将永远可用。
  2. 容器包含代码。这更像是一项政策,但我们公司不鼓励通过部署交付代码。至少不是以非混淆的形式。

由于这两个问题,我们决定将逻辑从 Kubernetes/Docker 直接转移到应用程序本身。

计划的解决方案

经过一些研究,我们偶然发现了 APP_BASE_HREF InjectionToken。因此,我们尝试遵循网络上的不同指南,根据应用程序部署的环境动态设置它。具体首先要做的是:

  1. /src/assets/config.json 中添加名为 config.json 的文件,内容中包含基本路径(和其他变量)
{
   "basePath": "/angular-app",
   "endpoint": "https://kubernetes.local/endpoint"
}
  1. 我们将代码添加到 main.ts 文件中,该文件通过父历史递归地探测当前 window.location.pathname 以找到 config.json
export type AppConfig = {
  basePath: string;
  endpoint: string;
};

export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

export async function fetchConfig(): Promise<AppConfig> {
  const pathName = window.location.pathname.split('/');

  for (const index of pathName.slice().reverse().keys()) {

    const path = pathName.slice(0, pathName.length - index).join('/');
    const url = stripSlashes(`${window.location.origin}/${path}/assets/config.json`);
    const promise = fetch(url);
    const [response, error] = await handle(promise) as [Response, any];

    if (!error && response.ok && response.headers.get('content-type') === 'application/json') {
      return await response.json();
    }
  }
  return null;
}

fetchConfig().then((config: AppConfig) => {
  platformBrowserDynamic([{ provide: APP_CONFIG, useValue: config }])
    .bootstrapModule(AppModule)
    .catch(err => console.error('An unexpected error occured: ', err));
});
  1. app.module.ts 内部,APP_BASE_HREF 使用 APP_CONFIG
  2. 初始化
@NgModule({
  providers: [
  {
    provide: APP_BASE_HREF,
    useFactory: (config: AppConfig) => {
      return config.basePath;
    },
    deps: [APP_CONFIG]
  }
  ]
})
export class AppModule { }

重要提示: 我们使用这种方法而不是使用 APP_INITIALIZER 因为当我们尝试它时 APP_BASE_HREF 的提供者总是 运行 在 APP_INITIALIZER 的提供程序之前,因此 APP_BASE_HREF 始终未定义。

不幸的是,这只在本地有效,在代理应用程序时不起作用。我们在这里观察到的问题是,当应用程序最初由网络服务器提供服务时没有指定 base-href 和 deploy-url,应用程序显然会尝试从“/”(root)加载所有内容。

但这也意味着它会尝试从那里获取 angular 脚本,即 main.jsvendor.jsruntime.js 和所有其他资产,因此 none 我们的代码实际上是 运行.

为了解决这个问题,我们稍微调整了代码。

我们没有让 angular 为 config.json 探测服务器,而是将代码直接放在 index.html 中并内联它。
像这样我们可以找到基本路径并将 html 中的所有链接替换为前缀链接,以至少加载脚本和其他资产。这看起来如下:

<body>
  <app-root></app-root>
  <script>
    function addScriptTag(d, src) {
      const script = d.createElement('script');
      script.type = 'text/javascript';
      script.onload = function(){
        // remote script has loaded
      };
      script.src = src;
      d.getElementsByTagName('body')[0].appendChild(script);
    }

    const pathName = window.location.pathname.split('/');

    const promises = [];

    for (const index of pathName.slice().reverse().keys()) {

      const path = pathName.slice(0, pathName.length - index).join('/');
      const url = `${window.location.origin}/${path}/assets/config.json`;
      const stripped = url.replace(/([^:]\/)\/+/gi, '')
      promises.push(fetch(stripped));
    }

    Promise.all(promises).then(result => {
      const response = result.find(response => response.ok && response.headers.get('content-type').includes('application/json'));
      if (response) {
        response.json().then(json => {
          document.querySelector('base').setAttribute('href', json.basePath);
          for (const node of document.querySelectorAll('script[src]')) {
            addScriptTag(document, `${json.basePath}/${node.getAttribute('src')}`);
            node.remove();
            window['app-config'] = json;
          }
        });
      }
    });
  </script>
</body>

此外,我们必须按如下方式调整 APP_BASE_HREF 提供程序中的代码:

useFactory: () => {
  const config: AppConfig = (window as {[key: string]: any})['app-config'];
  return config.basePath;
},
deps: []

现在发生的事情是,它加载页面,将源 url 替换为带前缀的源,加载脚本,加载应用程序并设置 APP_BASE_HREF.

路由似乎可以工作,但所有其他逻辑(如加载语言文件、降价文件和其他资产)不再工作。

我认为 --base-href 选项实际上设置了 APP_BASE_HREF 但我无法找到 --deploy-url 选项的作用。
大多数文章和 post 都指定指定 base-href 就足够了,资产也可以正常工作,但情况似乎并非如此。

问题

考虑到所有这些,我的问题是如何设计一个 Angular 应用程序以绝对能够设置其 base-href 和 deploy-url,以便所有 angular 路由、翻译、import() 等功能就像我通过 Angular CLI 设置的一样工作?

我不确定我是否提供了足够的信息来完全理解我们的问题是什么以及我们的期望,但如果没有,我会尽可能提供。

花了一些时间,但我们终于找到了一个足够简单的解决方案来涵盖我们在问题中提出的所有要点。

该解决方案非常接近于仅离线构建 Angular 应用程序并将内容复制到 nginx 容器中的原始方法,与之前在 init 容器中使用的构建过程混合.

我们的解决方案如下所示。我们首先离线构建应用程序并注入一些可识别的字符串,其中 base-hrefdeploy-url 应该在后面。

ng build --prod --base-href=http://recognisable-host-name/ --deploy-url=http://recognisable-host-name/

生成的 runtime*.jsindex.html 文件将在代码中包含这些内容,并作为加载资产的基础。

然后在第二阶段,标准 Angular 应用程序的 Dockerfile 改编自

FROM nginx:1.17.10-alpine

EXPOSE 80

COPY conf/nginx.conf /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*
COPY dist/ /usr/share/nginx/html/

这样到新版本

FROM nginx:1.17.10-alpine

EXPOSE 80

COPY conf/nginx.conf /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*
COPY dist/ /usr/share/nginx/html/

ENV CONTEXT_PATH http://localhost:80
ENV ENDPOINT http://localhost/endpoint
ENV VARIABLE foobar

CMD \
  mainFiles=$(ls /usr/share/nginx/html/main*.js) \
  && \
  for f in ${mainFiles}; do \
    envsubst '${ENDPOINT},${VARIABLE}' < "$f" > "${f}.tmp" && mv "${f}.tmp" "$f"; \
  done \
  && \
  runtimeFiles=$(grep -lr "recognisable-host-name" /usr/share/nginx/html) \
  && \
  for f in ${runtimeFiles}; do sed -i -E "s@http://recognisable-host-name/?@${CONTEXT_PATH}@g" "$f"; done \
  && \
  nginx -g 'daemon off;'

这里首先我们传递我们想要进行的替换。首先,CONTEXT_PATH 是将替换我们之前注入的 http://recognisable-host-name 字符串的完整基本路径,首先找到包含该字符串的所有文件,然后用 sed 命令替换它。

也可以通过使用 envsubst 并替换 main*.js 文件中所有提及的这些变量来替换可能是环境特定的其他变量。因此environments.prod.ts准备如下:

export const environment = {
    "endpoint": "${ENDPOINT}",
    "variable": "${VARIABLE}"
}

这涵盖了我比赛的所有要点,即:

  1. 不分享原码
  2. 快速部署
  3. 容器可以在任何代理之后提供(例如 Nginx-Ingress/Kubernetes)
  4. 可以注入环境变量

我希望它对其他人有所帮助,特别是因为在研究这个主题时我发现了数十篇文章,但 none 满足所有标准或需要大量复杂、低效或难以维护的自定义代码.

编辑:

如果有人感兴趣,我建立了一个演示项目,可以在其中测试此解决方案:
https://gitlab.com/satanik-angular/base-path-problem