React微前端实战教程(服务与运维篇)
传统单页面应用(SPA)是整体一块部署上线,服务器返回不同的SPA版本即可。而微前端是要求每小块APP都可以独立部署上线,同时又提供SPA般的体验。为了满足微前端+SPA
的需求,服务器需要有动态返回每小块APP对应版本的能力。只使用Nginx部署前端资源,是无法达到这个目标。
先看看我们如何设计react-micro-frontend-server-go来满足Demo网站的微前端服务诉求,再探讨一下满足7x24小时微前端的运维平台应该有哪些特点。
本地部署样例,见: https://github.com/kinsprite/react-micro-frontend-server-go/releases/tag/v2.3.0
1 微前端部署形态
在构建微前端框架与各个APP时,资源文件(CSS、JS、图片等)均根据内容Hash在文件名上满足长期缓存策略。但是,为了方便查看每个APP当前的使用版本与溯源,我们在部署URL上面添加${appName}/${gitRev}/
前缀。
2020-07-05:新版本只添加${appName}/
前缀。
1.1 资源文件部署目录结构
xxx@yyy: /opt/react-micro-frontend$ tree
.
├── 404.html
├── favicon.ico
├── logo192.png
├── logo512.png
├── rmf-3rd-echarts
│ └── v4.8.0
│ └── rmf-manifest.json
├── rmf-app-example
│ └── v1.1.0
│ ├── app-example.1c738c30.css
│ ├── app-example.1c738c30.css.map
│ ├── app-example.c5d5e9b7.js
│ ├── app-example.c5d5e9b7.js.map
│ └── rmf-manifest.json
├── rmf-app-example-echarts
│ └── 792735ad
│ ├── app-example-echarts.3c31ae87.css
│ ├── app-example-echarts.3c31ae87.css.map
│ ├── app-example-echarts.67238f12.js
│ ├── app-example-echarts.67238f12.js.map
│ └── rmf-manifest.json
├── rmf-app-example-sub
│ ├── d4426f42
│ │ ├── 1.17bc48b5.chunk.css
│ │ ├── 1.17bc48b5.chunk.css.map
│ │ ├── 1.1eedc88a.chunk.js
│ │ ├── 1.1eedc88a.chunk.js.map
│ │ ├── app-example-sub.399d08a6.js
│ │ ├── app-example-sub.399d08a6.js.map
│ │ ├── app-example-sub.44872610.css
│ │ ├── app-example-sub.44872610.css.map
│ │ └── rmf-manifest.json
│ └── v1.0.0
│ ├── 1.1eedc88a.chunk.js
│ ├── 1.1eedc88a.chunk.js.map
│ ├── 1.da13f959.chunk.css
│ ├── 1.da13f959.chunk.css.map
│ ├── app-example-sub.0f62ee23.js
│ ├── app-example-sub.0f62ee23.js.map
│ ├── app-example-sub.44872610.css
│ ├── app-example-sub.44872610.css.map
│ └── rmf-manifest.json
├── rmf-app-home
│ ├── c46d7f90
│ │ ├── app-home.83523b94.css
│ │ ├── app-home.83523b94.css.map
│ │ ├── app-home.a6bef461.js
│ │ ├── app-home.a6bef461.js.map
│ │ ├── assets
│ │ │ └── logo.103b5fa1.svg
│ │ └── rmf-manifest.json
│ └── v1.0.0
│ ├── app-home.77799b3a.css
│ ├── app-home.77799b3a.css.map
│ ├── app-home.f1972409.js
│ ├── app-home.f1972409.js.map
│ ├── assets
│ │ └── logo.103b5fa1.svg
│ └── rmf-manifest.json
├── rmf-framework
│ └── v2.2.0
│ ├── framework.383502c7.chunk.css
│ ├── framework.69d9d4a1.chunk.js
│ ├── framework.69d9d4a1.chunk.js.LICENSE.txt
│ ├── rmf-manifest.json
│ ├── runtime-framework.8ff857de.js
│ ├── vendor-react.6d3960e0.chunk.js
│ ├── vendor-react.6d3960e0.chunk.js.LICENSE.txt
│ └── vendor-redux.dcd59095.chunk.js
└── rmf-polyfill
└── v1.0.0
├── polyfill.54ef778c.js
├── polyfill-ie11.b38862c0.js
├── polyfill-ie11.b38862c0.js.LICENSE.txt
├── polyfill-ie9.06015e21.js
├── polyfill-ie9.06015e21.js.LICENSE.txt
└── rmf-manifest.json
我们的Demo网站是这样简单部署的,每新增一个微前端APP版本,只需新建一个文件夹,把资源文件复制过去,再使用HTTP API上线/下线某个APP版本。如果你不这样部署,请确保前端请求资源文件时路由到正确的位置,从前端看也是长这个样子的。
1.2 Nginx + Server-go配合服务
在Demo网站中,我们使用Nginx做WebServer,Server-go只提供SPA Index HTML与API服务。在Nginx配置中,与微前端相关的部分如下:
upstream react-micro-server-go {
server 127.0.0.1:8090;
}
server {
# ...
# server root
root /opt/react-micro-frontend;
# Error page
error_page 404 /404.html;
location = /404.html {
}
location = /robots.txt {
}
location = /favicon.ico {
expires 15d;
try_files $uri $uri/ =404;
}
location = /rmf-pwa.webmanifest {
expires 15d;
try_files $uri $uri/ =404;
}
location = /service-worker.js {
expires 1d;
try_files $uri $uri/ =404;
}
location ~* ^/logo.+ {
expires 15d;
try_files $uri $uri/ =404;
}
location ~* ^/rmf-.+ {
try_files $uri $uri/ =404;
}
location = /api/metadata/info {
expires epoch;
proxy_pass http://react-micro-server-go;
proxy_read_timeout 15;
}
location = /api/user/is-tester {
expires epoch;
proxy_pass http://react-micro-server-go;
proxy_read_timeout 15;
}
location = /api/user/login-as-tester {
expires epoch;
proxy_pass http://react-micro-server-go;
proxy_read_timeout 15;
}
location /api/ {
expires 3d;
return 404;
}
location / {
expires epoch;
proxy_pass http://react-micro-server-go;
proxy_read_timeout 15;
http2_push_preload on;
}
}
与API相关的请求,只让'/api/metadata/info’等几个通过。非微前端资源的请求,均交给Server-go返回Index HTML,同时开启HTTP2 Server Push功能。
Server-go-daemon的启动参数如下:
xxx@yyy:~$ cat /etc/init.d/react-micro-frontend-daemon
...
DAEMON_OPTS="-RMF_LISTEN_ADDRESS=127.0.0.1:8090 -RMF_STARTUP_INIT_DIR=/opt/react-micro-frontend -RMF_SERVE_STATIC_FILES=false -RMF_SITE_CONFIG_FILE=/etc/react-micro-frontend/site_config.yaml"
...
只监听本地环路127.0.0.1:8090,不怕其他人调用Intall/Uninstall API控制我们的微前端元信息。
在实际生产环境中,可以把资源文件放到CDN中,让Server-go在独立域名下服务。
在本地开发环境中,不需要Nginx,把需要部署资源文件与Server-go放到同一个目录,启动Server-go即可。
2 Server-go设计
2.1 功能点
主要功能:
A1. 启动时,加载本地文件系统上的微前端元信息文件。
A2. 前端SPA请求处理,动态生成Index HTML。HTML中包含微前端元信息、Polyfill、Framework。前端SPA的特点:前端做路由处理,后端请求到未匹配的路由时均返回Index HTML。
A3. 提供微前端版本上线、下线接口。需考虑与SPA Index HTML请求处理并发读写的情况。
次要功能:
B1. 提供微前端静态资源服务,方便本地开发部署。不依赖Nginx,就可以跑微前端服务器;也是从Service变成Server的原因。
B2. 可定制Index HTML模板,避免频繁升级server-go。
2.2 概要设计
- Server-go主要处理微前端元信息,这些内容比较少,一个APP版本的元信息最多1KB。100个APP X 5个版本,也才500KB。因此,我们在Server-go中设计一个缓存来存储这些元信息与framework runtime,避免跨服务调用,可以让Index HTML请求在1ms内处理完成。使用sync.Map确保Index HTML、Install App version、Uninstall App Version 3个请求可以并发处理。
// manifest_cache.go
type AppManifestCache struct {
FrameworkRuntimes sync.Map // entry URL to runtime JS contents, as map[key string]string
ServiceManifests sync.Map // serviceName to []*AppManifest, as map[key string] []*AppManifest
ServiceMutexes sync.Map // serviceName to *Mutex, for per app's Install and Uninstall
}
-
使用Gin做服务框架,其提供
router.NoRoute()
可以处理SPA Index HTML请求。再向AppManifestCache查询元信息并组装HTML内容。 -
Install/Uninstall App Version,只添加/删除元信息,由Index HTML请求确定返回什么版本给用户。Install之前,需要先部署好相应APP版本的资源文件。Uninstall时,先不要删除已部署的资源文件(因为我们在前端框架中未实现404失败重新获取APP元信息的功能)。
文件划分:
- main.go:使用Gin框架提供Web请求服务;启动时扫描初始化目录下面所有
rmf-manifest.json
,发现微前端并加载元信息、Framework runtime。 - manifest_cache.go: 元信息缓存中心,查询、Install与Uninstall的大部分逻辑都在这里。
- metadata.go:元信息数据结构定义。也负责把用户请求的元信息转化成Index HTML。
- site_config.go:自定义HTML模板相关的配置定义与加载。
2.3 核心代码解读
- main函数:值得注意的是
c.Writer.Header().Add("Link", pushLink)
配合Nginx开启HTTP2 Server Push功能。
// main.go
func main() {
parseFlags()
if siteConfigFile != "" {
LoadSiteConfig(siteConfigFile)
}
walkAppsResult := walkAppFiles(startupInitDir)
cache := NewAppManifestCache()
for _, filename := range walkAppsResult.ManifestFiles {
cache.LoadAppManifest(filename)
}
cache.CacheFrameworkRuntimes(startupInitDir)
router := gin.Default()
router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "OK",
})
})
router.GET("/api/metadata/info", func(c *gin.Context) {
info := cache.GenerateMetadata(true, true)
c.JSONP(http.StatusOK, &Metadata{
Apps: info.OtherApps,
Extra: globalSiteConfig.Extra,
})
})
router.POST("/api/metadata/install-app-version", func(c *gin.Context) {
var param AppInstallParam
if err := c.BindJSON(¶m); err != nil {
return
}
ok := cache.InstallAppVersion(¶m)
c.JSON(http.StatusOK, gin.H{
"install": ok,
})
})
router.POST("/api/metadata/uninstall-app-version", func(c *gin.Context) {
var param AppUninstallParam
if err := c.BindJSON(¶m); err != nil {
return
}
ok := cache.UninstallAppVersion(¶m)
c.JSON(http.StatusOK, gin.H{
"uninstall": ok,
})
})
if serveStaticFiles {
for _, appDir := range walkAppsResult.AppDirs {
router.Static("/"+appDir, path.Join(startupInitDir, appDir))
}
router.StaticFile("/favicon.ico", path.Join(startupInitDir, "favicon.ico"))
router.StaticFile("/logo192.png", path.Join(startupInitDir, "logo192.png"))
}
// SPA
router.NoRoute(func(c *gin.Context) {
info := cache.GenerateMetadata(true, true)
userAgent := c.Request.UserAgent()
HTML, pushLink := info.GenerateIndexHTML(userAgent)
if pushLink != "" {
c.Writer.Header().Add("Link", pushLink)
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(HTML))
})
fmt.Println("Serve on: ", listenAddress)
router.Run(listenAddress)
}
- GenerateMetadata函数: 给用户请求生成元信息,生成Index HTML时会使用到它。其中的
polyfill
与framework
微前端需要特殊处理。Polyfill则需要从微前端元信息的Entries中取出一个与用户浏览器最匹配的JS URL。Framework则需要把Runtime JS内容取出直接嵌入到Index HTML中。APP有多个版本上,随机取出一个版本给用户——这是我们Demo网站的简单实现。2020-07-05: 新版本已经支持按激活百分比概率分布。
// manifest_cache.go
// GenerateMetadata Generate Metadata for user request
func (cache *AppManifestCache) GenerateMetadata(isDev bool, inlineRuntime bool) *MetadataInfoForRequest {
info := &MetadataInfoForRequest{}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
cache.ServiceManifests.Range(func(key, value interface{}) bool {
serviceName := key.(string)
manifests := value.([]*AppManifest)
mLen := len(manifests)
if mLen == 0 {
return true
}
selIdx := 0
if mLen > 1 {
selIdx = r.Intn(mLen)
}
app := manifests[selIdx].ConvertToMetadataApp()
if serviceName == polyfillServiceName {
info.PolyfillApp = *app
} else if serviceName == frameworkServiceName {
cache.AppendFrameworkAppInfo(info, app, inlineRuntime)
} else {
info.OtherApps = append(info.OtherApps, *app)
}
return true
})
return info
}
// AppendFrameworkAppInfo Append Framework App Info
func (cache *AppManifestCache) AppendFrameworkAppInfo(
info *MetadataInfoForRequest, frameApp *MetadataApp, inlineRuntime bool) {
if !inlineRuntime {
info.FrameworkApp = *frameApp
return
}
for i, entry := range frameApp.Entries {
if strings.Contains(entry, frameworkRuntimeFilePrefix) {
content, ok := cache.FrameworkRuntimes.Load(entry)
if ok {
info.FrameworkRuntime = content.(string)
frameAppEntries := append([]string{}, frameApp.Entries[:i]...)
frameAppEntries = append(frameAppEntries, frameApp.Entries[i+1:]...)
info.FrameworkApp = *frameApp
info.FrameworkApp.Entries = frameAppEntries
return
}
}
}
info.FrameworkApp = *frameApp
}
- Install/Uninstall函数:这两个函数比较简单,只是带锁操作缓存。
// manifest_cache.go
// InstallAppVersion Install an new App version after the static files have been deployed.
func (cache *AppManifestCache) InstallAppVersion(app *AppInstallParam) bool {
mtxValue, _ := cache.ServiceMutexes.LoadOrStore(app.Manifest.ServiceName, &sync.Mutex{})
mtx := mtxValue.(*sync.Mutex)
mtx.Lock()
defer mtx.Unlock()
// Save the runtime thunk's content first
for url, content := range app.FrameworkRuntimes {
cache.FrameworkRuntimes.Store(url, content)
}
// Save the manifest
value, ok := cache.ServiceManifests.Load(app.Manifest.ServiceName)
appManifests := []*AppManifest{}
if ok {
// DON'T change the old slice
appManifests = append(appManifests, value.([]*AppManifest)...)
}
appManifests = append(appManifests, &app.Manifest)
cache.ServiceManifests.Store(app.Manifest.ServiceName, appManifests)
return true
}
// UninstallAppVersion Uninstall an deployed App version. NOTE: Leave cache.FrameworkRuntimes unchanged.
func (cache *AppManifestCache) UninstallAppVersion(app *AppUninstallParam) bool {
mtxValue, _ := cache.ServiceMutexes.LoadOrStore(app.ServiceName, &sync.Mutex{})
mtx := mtxValue.(*sync.Mutex)
mtx.Lock()
defer mtx.Unlock()
value, ok := cache.ServiceManifests.Load(app.ServiceName)
if !ok {
return false
}
appManifests := value.([]*AppManifest)
// Find the app by GitRevision
isFound := false
newManifests := []*AppManifest{}
last := 0
for i, manifest := range appManifests {
if manifest.GitRevision.Equal(&app.GitRevision) {
isFound = true
newManifests = append(newManifests, appManifests[last:i]...)
last = i + 1
}
}
if isFound {
newManifests = append(newManifests, appManifests[last:]...)
cache.ServiceManifests.Store(app.ServiceName, newManifests)
}
return isFound
}
3 Demo网站微前端运维
- 先把微前端APP资源文件按照目录结构要求部署到服务器上。
- Install App Version: 向 http://127.0.0.1:8090/api/metadata/install-app-version POST 如下格式的JSON,即可启用新版本。启用的
frameworkRuntimes
字段内容,只有部署framework
才需要填写内容。
{
"manifest":{
"entrypoints": [
"/rmf-app-example/v0.3.0/app-example.1c738c30.css",
"/rmf-app-example/v0.3.0/app-example.0e9db010.js"
],
"files": {
"app-example.css": "/rmf-app-example/v0.3.0/app-example.1c738c30.css",
"app-example.js": "/rmf-app-example/v0.3.0/app-example.0e9db010.js",
"app-example.js.map": "/rmf-app-example/v0.3.0/app-example.0e9db010.js.map",
"app-example.1c738c30.css.map": "/rmf-app-example/v0.3.0/app-example.1c738c30.css.map",
"index.html": "/rmf-app-example/v0.3.0/index.html"
},
"gitRevision": {
"tag": "v0.3.0"
},
"libraryExport": "rmfAppExample",
"publicPath": "/rmf-app-example/v0.3.0/",
"routes": [
"/app-example"
],
"render": "root",
"serviceName": "app-example"
},
"frameworkRuntimes": {
"/rmf-framework/v0.4.0/runtime-framework.invalid04a0.js": "(function (b) {})(true);"
}
}
- Uninstall App Version: 向 http://127.0.0.1:8090/api/metadata/uninstall-app-version POST 如下格式的JSON即可。新用户请求Index HTML时,就不会获得到已经卸载的APP版本。
{
"gitRevision": {
"tag": "v0.3.0"
},
"serviceName": "app-example"
}
4 微前端运维平台特点
Server-go只满足微前端的初始需求,对每个APP版本还无法控制允许哪些用户使用,离企业应用还有一段距离。在企业应用中,要满足7x24小时的服务质量,微前端的运维平台应该还有以下特点:
- 控制产品Index HTML可见哪些微前端APP。
- 微前端框架要完全后向兼容,否则运维平台需要能控制每个产品的Index HTML可以使用哪个版本的框架与APP。
- 控制APP版本的用户范围:正式用户、测试用户、抢先体验用户。
- 支持金丝雀发布,让部分用户先使用新版本:APP版本百分比控制。
- DevOps全流程打通,让开发、测试、生产环境容易上新版本。
- 类似Server-go的服务需满足高可用。
- 统计APP版本的使用情况,图表显示。
除了运维平台,Server-go也需要如下改造才能支持这些特点:
- 使用Redis缓存与持久化APP元信息,Server-go启动时从Redis读取,而不是本地文件。
- Install/Uninstall App Version时,除了修改本服务缓存,还需更新Redis与通知其它的Server-go实例,可采用MQ机制(Redis、Kafka均可)。
- GenerateMetadata()需要改造才能支持APP版本百分比控制、用户组控制、可见控制。
2020-07-05: 新版本已经支持百分比控制、用户组控制。