软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

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 概要设计

  1. 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
}
  1. 使用Gin做服务框架,其提供router.NoRoute()可以处理SPA Index HTML请求。再向AppManifestCache查询元信息并组装HTML内容。

  2. Install/Uninstall App Version,只添加/删除元信息,由Index HTML请求确定返回什么版本给用户。Install之前,需要先部署好相应APP版本的资源文件。Uninstall时,先不要删除已部署的资源文件(因为我们在前端框架中未实现404失败重新获取APP元信息的功能)。

文件划分

  1. main.go:使用Gin框架提供Web请求服务;启动时扫描初始化目录下面所有rmf-manifest.json,发现微前端并加载元信息、Framework runtime。
  2. manifest_cache.go: 元信息缓存中心,查询、Install与Uninstall的大部分逻辑都在这里。
  3. metadata.go:元信息数据结构定义。也负责把用户请求的元信息转化成Index HTML。
  4. site_config.go:自定义HTML模板相关的配置定义与加载。

2.3 核心代码解读

  1. 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(&param); err != nil {
            return
        }

        ok := cache.InstallAppVersion(&param)

        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(&param); err != nil {
            return
        }

        ok := cache.UninstallAppVersion(&param)
        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)
}
  1. GenerateMetadata函数: 给用户请求生成元信息,生成Index HTML时会使用到它。其中的polyfillframework微前端需要特殊处理。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
}
  1. 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网站微前端运维

  1. 先把微前端APP资源文件按照目录结构要求部署到服务器上。
  2. 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);"
 }
}
  1. 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小时的服务质量,微前端的运维平台应该还有以下特点:

  1. 控制产品Index HTML可见哪些微前端APP。
  2. 微前端框架要完全后向兼容,否则运维平台需要能控制每个产品的Index HTML可以使用哪个版本的框架与APP。
  3. 控制APP版本的用户范围:正式用户、测试用户、抢先体验用户。
  4. 支持金丝雀发布,让部分用户先使用新版本:APP版本百分比控制。
  5. DevOps全流程打通,让开发、测试、生产环境容易上新版本。
  6. 类似Server-go的服务需满足高可用。
  7. 统计APP版本的使用情况,图表显示。

除了运维平台,Server-go也需要如下改造才能支持这些特点:

  1. 使用Redis缓存与持久化APP元信息,Server-go启动时从Redis读取,而不是本地文件。
  2. Install/Uninstall App Version时,除了修改本服务缓存,还需更新Redis与通知其它的Server-go实例,可采用MQ机制(Redis、Kafka均可)。
  3. GenerateMetadata()需要改造才能支持APP版本百分比控制、用户组控制、可见控制。

2020-07-05: 新版本已经支持百分比控制、用户组控制。