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 构建应用程序,并且特定变量是根据部署此应用程序的环境进行替换。
除了它产生的一些问题外,这种方法非常有效:
- 一次部署需要几分钟
每次重新部署应用程序时,initContainer 都需要 运行。当然,这总是 运行s ng build
命令。为防止每次容器已经 运行s 之前的 RUN
指令中的 ng build
命令缓存这些时,它都会构建 ES 模块。
尽管如此,用于差载和 Terser 等的建筑物再次 运行ning 需要几分钟才能完成。
当存在水平 pod 自动缩放时,额外的 pods 将永远可用。
- 容器包含代码。这更像是一项政策,但我们公司不鼓励通过部署交付代码。至少不是以非混淆的形式。
由于这两个问题,我们决定将逻辑从 Kubernetes/Docker 直接转移到应用程序本身。
计划的解决方案
经过一些研究,我们偶然发现了 APP_BASE_HREF
InjectionToken。因此,我们尝试遵循网络上的不同指南,根据应用程序部署的环境动态设置它。具体首先要做的是:
- 在
/src/assets/config.json
中添加名为 config.json
的文件,内容中包含基本路径(和其他变量)
{
"basePath": "/angular-app",
"endpoint": "https://kubernetes.local/endpoint"
}
- 我们将代码添加到
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));
});
- 在
app.module.ts
内部,APP_BASE_HREF
使用 APP_CONFIG
初始化
@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.js
、vendor.js
、runtime.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-href
和 deploy-url
应该在后面。
ng build --prod --base-href=http://recognisable-host-name/ --deploy-url=http://recognisable-host-name/
生成的 runtime*.js
和 index.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}"
}
这涵盖了我比赛的所有要点,即:
- 不分享原码
- 快速部署
- 容器可以在任何代理之后提供(例如 Nginx-Ingress/Kubernetes)
- 可以注入环境变量
我希望它对其他人有所帮助,特别是因为在研究这个主题时我发现了数十篇文章,但 none 满足所有标准或需要大量复杂、低效或难以维护的自定义代码.
编辑:
如果有人感兴趣,我建立了一个演示项目,可以在其中测试此解决方案:
https://gitlab.com/satanik-angular/base-path-problem
简介
我知道这个问题已经以多种不同的形式提出。但是我已经阅读了所有 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 构建应用程序,并且特定变量是根据部署此应用程序的环境进行替换。
除了它产生的一些问题外,这种方法非常有效:
- 一次部署需要几分钟
每次重新部署应用程序时,initContainer 都需要 运行。当然,这总是 运行sng build
命令。为防止每次容器已经 运行s 之前的RUN
指令中的ng build
命令缓存这些时,它都会构建 ES 模块。
尽管如此,用于差载和 Terser 等的建筑物再次 运行ning 需要几分钟才能完成。
当存在水平 pod 自动缩放时,额外的 pods 将永远可用。 - 容器包含代码。这更像是一项政策,但我们公司不鼓励通过部署交付代码。至少不是以非混淆的形式。
由于这两个问题,我们决定将逻辑从 Kubernetes/Docker 直接转移到应用程序本身。
计划的解决方案
经过一些研究,我们偶然发现了 APP_BASE_HREF
InjectionToken。因此,我们尝试遵循网络上的不同指南,根据应用程序部署的环境动态设置它。具体首先要做的是:
- 在
/src/assets/config.json
中添加名为config.json
的文件,内容中包含基本路径(和其他变量)
{
"basePath": "/angular-app",
"endpoint": "https://kubernetes.local/endpoint"
}
- 我们将代码添加到
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));
});
- 在
app.module.ts
内部,APP_BASE_HREF
使用APP_CONFIG
初始化
@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.js
、vendor.js
、runtime.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-href
和 deploy-url
应该在后面。
ng build --prod --base-href=http://recognisable-host-name/ --deploy-url=http://recognisable-host-name/
生成的 runtime*.js
和 index.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}"
}
这涵盖了我比赛的所有要点,即:
- 不分享原码
- 快速部署
- 容器可以在任何代理之后提供(例如 Nginx-Ingress/Kubernetes)
- 可以注入环境变量
我希望它对其他人有所帮助,特别是因为在研究这个主题时我发现了数十篇文章,但 none 满足所有标准或需要大量复杂、低效或难以维护的自定义代码.
编辑:
如果有人感兴趣,我建立了一个演示项目,可以在其中测试此解决方案:
https://gitlab.com/satanik-angular/base-path-problem