容器化单页面应用中RESTful API的访问
最近在工作中,需要讓運行在容器中的單頁面應用程序能夠訪問外部的RESTful API。這個需求看起來并不困難,不過實現起來還是有些曲折的。在此,我就將這部分內容總結一下。
在入正題之前,有個一問題,就是為什么要將單頁面應用放在容器中運行?這個問題其實跟“為什么要將應用程序容器化”是一個問題。簡單來講,容器化的應用程序可以運行在任何具有容器執行環境的宿主平臺上,比如可以在Linux系統中運行容器,也可以在MacOS或者Windows下使用Docker Desktop for Mac或者Docker for Windows來運行容器化的應用程序。無論在什么平臺中運行,容器化的應用程序都可以使用統一化的配置方式(比如環境變量、虛擬磁盤路徑的掛載等),并向外界提供一致的訪問端點。將應用程序容器化最重要的一點是,通過它可以非常方便地將應用程序部署在云環境中,使應用程序具有很好的橫向擴展能力,而且跨云的遷移也變得非常便捷。由此可見,通過容器的使用,我們可以采用不同的技術來實現應用程序的不同部分,然后可以得到統一的部署和運維體驗,這一點對于微服務架構的實踐有著非常深遠的意義。在工作中,我所接觸的系統包含了多個團隊的貢獻,有的團隊使用nodejs,有的團隊使用Scala,有的團隊使用Go,這些獨立分散的項目都以一個個獨立的服務進行開發和交付,最終通過容器化的途徑實現了整個應用程序的一體化部署。當然,與各種軟件架構風格類似,微服務架構也是有利有弊,并不是所有的項目和團隊都應該采用這種架構,還是應該根據項目和團隊的實際情況來決定軟件系統的架構方式,這部分內容就不在此過多討論了。
回到本文的主題,我會通過一個案例來總結在不同場景下,容器化單頁面應用訪問RESTful API的方式。
我們的案例是一個提供名稱列表的RESTful API,外加一個顯示名稱列表的前端單頁面應用。不必理會什么是“名稱列表”,它只不過是一個字符串列表。在這里我們也不必關心這個字符串列表包含哪些內容,只要讓單頁面應用能夠訪問到這個RESTful API即可。繼續閱讀本文,你將了解到這個案例是多么的簡單。
RESTful API
首先創建一個能夠返回名稱列表的RESTful API,實現方式有很多種,我選擇我熟悉的ASP.NET Core Web API項目來創建RESTful API。在命令行執行以下命令以創建一個ASP.NET Core Web API的項目:
| dotnet new webapi --name NameList.Service | 
然后,使用Visual Studio Code編輯器打開該項目,刪除ValuesController,然后新增NamesController,當然也可以基于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中刪除,同時調整launchSettings.json文件,直接偵聽http://*:5000,然后使用dotnet run命令,啟動RESTful API,使用cURL工具進行測試:
| 1 2 | $ curl -s http://localhost:5000/api/names ["Brian","Frank","Sunny","Chris"] | 
API調用成功。為了后續的實驗能夠順利進行,我們在服務端啟用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); ????} } | 
接下來,開發我們的前端單頁面應用,以調用該API并將名稱列表顯示在前端頁面。
單頁面應用
同樣,前端頁面也可以采用很多種框架和技術進行開發,比如使用React、Vue或者Angular,或者直接使用jQuery,都可以完成我們的目標。我還是選擇我最熟悉的Angular 7,依照下面的步驟開發這個單頁面應用。
首先,使用Angular CLI,創建我們的應用程序:
在回答幾個問題之后(使用默認選項即可),前端單頁面應用也就創建好了,首先在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:
接著,新建一個AppService服務(app.service.ts),在該服務中提供一個getNames的方法,用以調用RESTful API以獲取名稱列表,并將獲得的列表返回給調用方:
| 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,以便在頁面初始化的時候,調用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> | 
現在,將RESTful API運行起來,然后使用ng serve命令將前端頁面也運行起來,應該能夠看到下面的效果:
接下來,我們將RESTful API和前端頁面編譯成容器鏡像(Docker Images)。
現在,我們將上面開發的單頁面應用編譯成docker鏡像,然后讓它在容器中運行。在Angular項目的根目錄下,新建一個Dockerfile,內容如下:
| 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,因為最終我會將Angular單頁面應用運行在nginx上;然后,基于node鏡像,安裝Angular CLI,并將本地前端代碼復制到容器中的/src目錄下進行編譯,最終將編譯輸出的html、js、css以及相關資源復制到nginx容器的/usr/share/nginx/html目錄下,最后啟動nginx來服務單頁面應用站點。
現在,我們啟動RESTful API,依舊讓其偵聽5000端口,然后通過以下docker命令,啟動這個前端單頁面應用容器:
| 1 | docker run -it -p 8088:80 namelist-client | 
容器啟動后,打開瀏覽器,訪問8088端口,我們可以得到同樣的結果,可以注意到,前端頁面會發送請求到http://localhost:5000以獲得名稱列表:
整個實驗看似已經非常成功,但是,我們忽略了一個重要問題,目前RESTful API的地址在前端代碼中是寫死(hard code)的,即使是在environment.prod.ts文件中指定,也是編譯時就已經確定的事情,那如果RESTful API部署在不同的機器上,或者偵聽端口不是5000呢?這樣的話,前端單頁面應用是無法訪問RESTful API服務的。下面我們就來解決這個問題。
有一種比較簡單粗暴的辦法,就是在編譯的時候,通過持續集成環境的設置,將RESTful API的地址寫入environment.prod.ts文件中,但這樣編譯出來的容器只能在特定環境下運行,否則前端頁面還是無法訪問RESTful API。要讓容器能夠通用,還是應該在容器啟動的時候,以環境變量的方式將RESTful API的地址注入到容器中。在此,我們討論兩種場景:RESTful API獨立部署的場景,以及RESTful API也以容器的方式運行的場景。
RESTful API獨立部署的場景
首先做個實驗,將前端Angular項目中environment.prod.ts里的serviceUri改為一個相對路徑,比如:
| 1 2 3 4 | export const environment = { ??production: true, ??serviceUri: '/name-service' }; | 
重新將前端應用編譯成docker鏡像并執行,不出意料,頁面無法正確加載,因為調用的RESTful API地址不正確,調用返回404:
接下來,可以使用nginx的反向代理功能,將/name-service的部分proxy_pass到真實的RESTful API地址,而真實的RESTful API地址可以在nginx的配置中通過讀取環境變量來動態設置。在前端代碼的根目錄下,新建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模塊,讀取系統環境變量并在nginx中使用這個環境變量,然后設置location,指定當客戶端請求/name-service時,將請求proxy_pass到由API_URI環境變量設置的RESTful API地址。由于需要使用perl模塊,所以,Dockerfile也要做相應修改:
| 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文件復制到nginx容器中的/etc/nginx目錄。之后,重新編譯前端docker鏡像。
現在,啟動容器時就可以使用-e參數指定RESTful API的地址了:
| 1 | docker run -it -p 8088:80 -e API_URI=192.168.0.107:5000 namelist-client | 
再次刷新前端頁面,可以看到,頁面正確顯示,API調用成功:
RESTful API容器化的場景
如果我們將RESTful API也容器化,并與前端應用一起在容器中運行,那么就可以使用容器連接的方式,讓前端頁面訪問后端的API。此時,只需要對前端nginx.conf進行一些修改:
| 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; ????} } | 
分別使用以下兩條命令啟動RESTful API和前端應用容器:
| 1 2 | docker run -it --name namelist-service namelist-service docker run -it -p 8088:80 --link namelist-service namelist-client | 
注意到在啟動前端應用容器時,需要使用—link參數鏈接到namelist-service容器,而且服務端也不需要暴露出TCP端口,起到了一定的保護作用:
本文以容器為背景,結合nginx的使用,介紹了容器化單頁面應用中訪問RESTful API的兩種方法。由于單頁面應用無法讀取系統的環境變量,因此,解決RESTful API訪問地址的問題就變得稍微有點復雜。本文相關的案例源代碼:https://github.com/daxnet/name-list。
原文地址:https://sunnycoding.cn/2019/06/22/accessing-restful-api-in-dockerized-spa/
.NET社區新聞,深度好文,歡迎訪問公眾號文章匯總?http://www.csharpkit.com?
總結
以上是生活随笔為你收集整理的容器化单页面应用中RESTful API的访问的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 多租户通用权限设计(基于 casbin)
- 下一篇: LogoSharp:Logo语言的C#实
