容器化单页面应用中RESTful API的访问
最近在工作中,需要讓運(yùn)行在容器中的單頁面應(yīng)用程序能夠訪問外部的RESTful API。這個(gè)需求看起來并不困難,不過實(shí)現(xiàn)起來還是有些曲折的。在此,我就將這部分內(nèi)容總結(jié)一下。
在入正題之前,有個(gè)一問題,就是為什么要將單頁面應(yīng)用放在容器中運(yùn)行?這個(gè)問題其實(shí)跟“為什么要將應(yīng)用程序容器化”是一個(gè)問題。簡單來講,容器化的應(yīng)用程序可以運(yùn)行在任何具有容器執(zhí)行環(huán)境的宿主平臺上,比如可以在Linux系統(tǒng)中運(yùn)行容器,也可以在MacOS或者Windows下使用Docker Desktop for Mac或者Docker for Windows來運(yùn)行容器化的應(yīng)用程序。無論在什么平臺中運(yùn)行,容器化的應(yīng)用程序都可以使用統(tǒng)一化的配置方式(比如環(huán)境變量、虛擬磁盤路徑的掛載等),并向外界提供一致的訪問端點(diǎn)。將應(yīng)用程序容器化最重要的一點(diǎn)是,通過它可以非常方便地將應(yīng)用程序部署在云環(huán)境中,使應(yīng)用程序具有很好的橫向擴(kuò)展能力,而且跨云的遷移也變得非常便捷。由此可見,通過容器的使用,我們可以采用不同的技術(shù)來實(shí)現(xiàn)應(yīng)用程序的不同部分,然后可以得到統(tǒng)一的部署和運(yùn)維體驗(yàn),這一點(diǎn)對于微服務(wù)架構(gòu)的實(shí)踐有著非常深遠(yuǎn)的意義。在工作中,我所接觸的系統(tǒng)包含了多個(gè)團(tuán)隊(duì)的貢獻(xiàn),有的團(tuán)隊(duì)使用nodejs,有的團(tuán)隊(duì)使用Scala,有的團(tuán)隊(duì)使用Go,這些獨(dú)立分散的項(xiàng)目都以一個(gè)個(gè)獨(dú)立的服務(wù)進(jìn)行開發(fā)和交付,最終通過容器化的途徑實(shí)現(xiàn)了整個(gè)應(yīng)用程序的一體化部署。當(dāng)然,與各種軟件架構(gòu)風(fēng)格類似,微服務(wù)架構(gòu)也是有利有弊,并不是所有的項(xiàng)目和團(tuán)隊(duì)都應(yīng)該采用這種架構(gòu),還是應(yīng)該根據(jù)項(xiàng)目和團(tuán)隊(duì)的實(shí)際情況來決定軟件系統(tǒng)的架構(gòu)方式,這部分內(nèi)容就不在此過多討論了。
回到本文的主題,我會(huì)通過一個(gè)案例來總結(jié)在不同場景下,容器化單頁面應(yīng)用訪問RESTful API的方式。
我們的案例是一個(gè)提供名稱列表的RESTful API,外加一個(gè)顯示名稱列表的前端單頁面應(yīng)用。不必理會(huì)什么是“名稱列表”,它只不過是一個(gè)字符串列表。在這里我們也不必關(guān)心這個(gè)字符串列表包含哪些內(nèi)容,只要讓單頁面應(yīng)用能夠訪問到這個(gè)RESTful API即可。繼續(xù)閱讀本文,你將了解到這個(gè)案例是多么的簡單。
RESTful API
首先創(chuàng)建一個(gè)能夠返回名稱列表的RESTful API,實(shí)現(xiàn)方式有很多種,我選擇我熟悉的ASP.NET Core Web API項(xiàng)目來創(chuàng)建RESTful API。在命令行執(zhí)行以下命令以創(chuàng)建一個(gè)ASP.NET Core Web API的項(xiàng)目:
dotnet new webapi --name NameList.Service |
然后,使用Visual Studio Code編輯器打開該項(xiàng)目,刪除ValuesController,然后新增NamesController,當(dāng)然也可以基于ValuesController修改,代碼如下:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; namespace NameList.Service.Controllers { ????[Route("api/[controller]")] ????[ApiController] ????public class NamesController : ControllerBase ????{ ????????[HttpGet] ????????public ActionResult<IEnumerable<string>> Get() ????????????=> new string[] { "Brian", "Frank", "Sunny", "Chris" }; ????} } |
目前我們不需要啟用HTTPS重定向,將其從Startup.cs中刪除,同時(shí)調(diào)整launchSettings.json文件,直接偵聽http://*:5000,然后使用dotnet run命令,啟動(dòng)RESTful API,使用cURL工具進(jìn)行測試:
1 2 | $ curl -s http://localhost:5000/api/names ["Brian","Frank","Sunny","Chris"] |
API調(diào)用成功。為了后續(xù)的實(shí)驗(yàn)?zāi)軌蝽樌M(jìn)行,我們在服務(wù)端啟用CORS:
public class Startup { ????private const string CorsPolicy = "DefaultCorsPolicy"; ????public void ConfigureServices(IServiceCollection services) ????{ ????????services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); ????????services.AddCors(options => ????????{ ????????????options.AddPolicy(CorsPolicy, builder => ????????????{ ????????????????builder.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin(); ????????????}); ????????}); ????} ????public void Configure(IApplicationBuilder app, IHostingEnvironment env) ????{ ????????app.UseCors(CorsPolicy); ????} } |
接下來,開發(fā)我們的前端單頁面應(yīng)用,以調(diào)用該API并將名稱列表顯示在前端頁面。
單頁面應(yīng)用
同樣,前端頁面也可以采用很多種框架和技術(shù)進(jìn)行開發(fā),比如使用React、Vue或者Angular,或者直接使用jQuery,都可以完成我們的目標(biāo)。我還是選擇我最熟悉的Angular 7,依照下面的步驟開發(fā)這個(gè)單頁面應(yīng)用。
首先,使用Angular CLI,創(chuàng)建我們的應(yīng)用程序:
在回答幾個(gè)問題之后(使用默認(rèn)選項(xiàng)即可),前端單頁面應(yīng)用也就創(chuàng)建好了,首先在app.modules.ts中啟用HttpClientModule:
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { HttpClientModule } from '@angular/common/http'; @NgModule({ ??declarations: [ ????AppComponent ??], ??imports: [ ????BrowserModule, ????HttpClientModule ??], ??providers: [], ??bootstrap: [AppComponent] }) export class AppModule { } |
然后,在environment.ts和environment.prod.ts中加入RESTful API的BaseURI:
接著,新建一個(gè)AppService服務(wù)(app.service.ts),在該服務(wù)中提供一個(gè)getNames的方法,用以調(diào)用RESTful API以獲取名稱列表,并將獲得的列表返回給調(diào)用方:
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; @Injectable({ ??providedIn: 'root' }) export class AppService { ??constructor(private http: HttpClient) { } ??getNames(): Observable<string[]> { ????return this.http.get<string[]>(`${environment.serviceUri}/api/names`) ????.pipe( ??????tap(_ => console.log('fetched names')), ??????catchError(this.handleError<string[]>([])) ????); ??} ??private handleError<T>(result?: T) { ????return (error: any): Observable<T> => { ??????console.error(error); ??????return of(result as T); ????}; ??} } |
然后,修改app.component.ts,以便在頁面初始化的時(shí)候,調(diào)用AppService獲取名稱列表,并將獲得的列表保存在變量中:
import { Component, OnInit } from '@angular/core'; import { AppService } from './app.service'; @Component({ ??selector: 'app-root', ??templateUrl: './app.component.html', ??styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { ??names: string[]; ??constructor(private appService: AppService) { } ??ngOnInit(): void { ????this.getNames(); ??} ??getNames(): void { ????this.appService.getNames() ??????.subscribe(names => this.names = names); ??} } |
最后,修改app.component.html,通過HTML將獲得的名稱列表顯示在頁面上:
<h2>Names</h2> <ul> ??<li *ngFor="let name of names"> ????<span>{{name}}</span> ??</li> </ul> |
現(xiàn)在,將RESTful API運(yùn)行起來,然后使用ng serve命令將前端頁面也運(yùn)行起來,應(yīng)該能夠看到下面的效果:
接下來,我們將RESTful API和前端頁面編譯成容器鏡像(Docker Images)。
現(xiàn)在,我們將上面開發(fā)的單頁面應(yīng)用編譯成docker鏡像,然后讓它在容器中運(yùn)行。在Angular項(xiàng)目的根目錄下,新建一個(gè)Dockerfile,內(nèi)容如下:
FROM nginx AS base WORKDIR /app EXPOSE 80 FROM node:10.16.0-alpine AS build RUN npm install -g @angular/cli@8.0.3 WORKDIR /src COPY . . RUN npm install RUN ng build --prod --output-path /app FROM base AS final COPY --from=build /app /usr/share/nginx/html CMD ["nginx", "-g", "daemon off;"] |
大概介紹一下,在上面的Dockerfile中,將nginx定義為base image,因?yàn)樽罱K我會(huì)將Angular單頁面應(yīng)用運(yùn)行在nginx上;然后,基于node鏡像,安裝Angular CLI,并將本地前端代碼復(fù)制到容器中的/src目錄下進(jìn)行編譯,最終將編譯輸出的html、js、css以及相關(guān)資源復(fù)制到nginx容器的/usr/share/nginx/html目錄下,最后啟動(dòng)nginx來服務(wù)單頁面應(yīng)用站點(diǎn)。
現(xiàn)在,我們啟動(dòng)RESTful API,依舊讓其偵聽5000端口,然后通過以下docker命令,啟動(dòng)這個(gè)前端單頁面應(yīng)用容器:
1 | docker run -it -p 8088:80 namelist-client |
容器啟動(dòng)后,打開瀏覽器,訪問8088端口,我們可以得到同樣的結(jié)果,可以注意到,前端頁面會(huì)發(fā)送請求到http://localhost:5000以獲得名稱列表:
整個(gè)實(shí)驗(yàn)看似已經(jīng)非常成功,但是,我們忽略了一個(gè)重要問題,目前RESTful API的地址在前端代碼中是寫死(hard code)的,即使是在environment.prod.ts文件中指定,也是編譯時(shí)就已經(jīng)確定的事情,那如果RESTful API部署在不同的機(jī)器上,或者偵聽端口不是5000呢?這樣的話,前端單頁面應(yīng)用是無法訪問RESTful API服務(wù)的。下面我們就來解決這個(gè)問題。
有一種比較簡單粗暴的辦法,就是在編譯的時(shí)候,通過持續(xù)集成環(huán)境的設(shè)置,將RESTful API的地址寫入environment.prod.ts文件中,但這樣編譯出來的容器只能在特定環(huán)境下運(yùn)行,否則前端頁面還是無法訪問RESTful API。要讓容器能夠通用,還是應(yīng)該在容器啟動(dòng)的時(shí)候,以環(huán)境變量的方式將RESTful API的地址注入到容器中。在此,我們討論兩種場景:RESTful API獨(dú)立部署的場景,以及RESTful API也以容器的方式運(yùn)行的場景。
RESTful API獨(dú)立部署的場景
首先做個(gè)實(shí)驗(yàn),將前端Angular項(xiàng)目中environment.prod.ts里的serviceUri改為一個(gè)相對路徑,比如:
1 2 3 4 | export const environment = { ??production: true, ??serviceUri: '/name-service' }; |
重新將前端應(yīng)用編譯成docker鏡像并執(zhí)行,不出意料,頁面無法正確加載,因?yàn)檎{(diào)用的RESTful API地址不正確,調(diào)用返回404:
接下來,可以使用nginx的反向代理功能,將/name-service的部分proxy_pass到真實(shí)的RESTful API地址,而真實(shí)的RESTful API地址可以在nginx的配置中通過讀取環(huán)境變量來動(dòng)態(tài)設(shè)置。在前端代碼的根目錄下,新建nginx.conf文件:
load_module "modules/ngx_http_perl_module.so"; env API_URI; events { ????worker_connections 1024; } http { ????perl_set $api_uri 'sub { return $ENV{"API_URI"}; }'; ????server { ??????listen??????? 80; ??????server_name?? localhost; ??????include? /etc/nginx/mime.types; ??????location / { ????????root /usr/share/nginx/html; ????????index? index.html? index.htm; ??????} ??????location ~ ^/name-service/(.*)$ { ????????rewrite ^ $request_uri; ????????rewrite ^/name-service/(.*)$ $1 break; ????????return 400; ??????} ????} } |
該配置文件通過使用nginx的perl模塊,讀取系統(tǒng)環(huán)境變量并在nginx中使用這個(gè)環(huán)境變量,然后設(shè)置location,指定當(dāng)客戶端請求/name-service時(shí),將請求proxy_pass到由API_URI環(huán)境變量設(shè)置的RESTful API地址。由于需要使用perl模塊,所以,Dockerfile也要做相應(yīng)修改:
FROM nginx:perl AS base WORKDIR /app EXPOSE 80 FROM node:10.16.0-alpine AS build RUN npm install -g @angular/cli@8.0.3 WORKDIR /src COPY . . RUN npm install RUN ng build --prod --output-path /app FROM base AS final COPY --from=build /app /usr/share/nginx/html COPY --from=build /src/nginx.conf /etc/nginx/nginx.conf CMD ["nginx", "-g", "daemon off;"] |
Base Image由nginx改為nginx:perl,然后需要將nginx.conf文件復(fù)制到nginx容器中的/etc/nginx目錄。之后,重新編譯前端docker鏡像。
現(xiàn)在,啟動(dòng)容器時(shí)就可以使用-e參數(shù)指定RESTful API的地址了:
1 | docker run -it -p 8088:80 -e API_URI=192.168.0.107:5000 namelist-client |
再次刷新前端頁面,可以看到,頁面正確顯示,API調(diào)用成功:
RESTful API容器化的場景
如果我們將RESTful API也容器化,并與前端應(yīng)用一起在容器中運(yùn)行,那么就可以使用容器連接的方式,讓前端頁面訪問后端的API。此時(shí),只需要對前端nginx.conf進(jìn)行一些修改:
events { ????worker_connections 1024; } http { ????server { ??????listen??????? 80; ??????server_name?? localhost; ??????include? /etc/nginx/mime.types; ??????location / { ????????root /usr/share/nginx/html; ????????index? index.html? index.htm; ??????} ??????location ~ ^/name-service/(.*)$ { ??????} ????} ????upstream namelist-service { ????????server namelist-service:5000; ????} } |
分別使用以下兩條命令啟動(dòng)RESTful API和前端應(yīng)用容器:
1 2 | docker run -it --name namelist-service namelist-service docker run -it -p 8088:80 --link namelist-service namelist-client |
注意到在啟動(dòng)前端應(yīng)用容器時(shí),需要使用—link參數(shù)鏈接到namelist-service容器,而且服務(wù)端也不需要暴露出TCP端口,起到了一定的保護(hù)作用:
本文以容器為背景,結(jié)合nginx的使用,介紹了容器化單頁面應(yīng)用中訪問RESTful API的兩種方法。由于單頁面應(yīng)用無法讀取系統(tǒng)的環(huán)境變量,因此,解決RESTful API訪問地址的問題就變得稍微有點(diǎn)復(fù)雜。本文相關(guān)的案例源代碼:https://github.com/daxnet/name-list。
原文地址:https://sunnycoding.cn/2019/06/22/accessing-restful-api-in-dockerized-spa/
.NET社區(qū)新聞,深度好文,歡迎訪問公眾號文章匯總?http://www.csharpkit.com?
總結(jié)
以上是生活随笔為你收集整理的容器化单页面应用中RESTful API的访问的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 多租户通用权限设计(基于 casbin)
- 下一篇: Hello Kubernetes快速交互