前提铺垫:
说到微前端,也许大家想到很多,比如single-spa, qiankun等, 而这种属于对巨石项目, 做大力度的拆分, 而假如是小业务, 比如一个板块, 一个页面, 一个功能, 甚至再小到一个组件模块, 此时大家想到的也许是webpack 5的模块联邦或者借助Eluxjs。
但是这些都不算灵活,比如大部分的业务场景中,webpack就不适合升级, 而且
用微模块,常想到的是借助于打包工具:
- 静态编译:微模块作为一个NPM包被安装到工程中,通过打包工具(如webpack)正常编译打包即可。这种方式的优点是代码产物得到打包工具的各种去重和优化;缺点是当某个模块更新时,需要整体重新打包。
- 动态注入:利用
ModuleFederation
,将微模块作为子应用独立部署,与时下流行的微前端类似。这种方式的优点是某子应用中的微模块更新时,依赖该微模块的其它应用无需重新编译,刷新浏览器即可动态获取最新模块;缺点是没有打包工具的整体编译与优化,代码和资源容易重复加载或冲突。
其实还有一点就是使用静态资源的动态加载,比如hel-micro: 他实现了模块联邦sdk化,总结下,他的主要功能是远程模块方式导入
优点:
- 跨项目共享模块简单
-
免构建、热更新(注意: 这里的免构建是, 使用方免除构建)
缺点:
- 需要配套的基础设施,也就是包管理来实现灰度管理和发布
- 远程资源获取的快慢要自己来处理, 比如你已经有了cdn,那么这个问题就方便解决了
正题:
接下来就是针对缺点一, 来搭建基础设施,设计出一整套可投入生产的基建方案:
首先:我给出使用案例:视频中对于已经存在的项目A, 内嵌了模块B, 模块B是个单独的项目,当我选择发布模块B,项目A的模块B会自动更新,也可以在后台中,手动选择A项目中应该展示哪个版本的模块B:
而支持上面的灰度平台又需要我们在工程化脚本中给出支持
视频案例:(ps:视频中服务器已做迁移,暂停了)
微模块上传案例
上传的服务(golang)
功能点总结来说 前端资源文件的整理上传+版本信息获取
表设计:
分别管理模块和版本
模块:
案例数据
版本:
案例数据
用户权限表:
这张表中的modulesInfo字段很重要, 这个是最后来实现灰度发布的关键: (而对于各公司来说,最需要处理的就是沟通,允许在用户表中,添加这个字段, 如果没有这个字段,微模块的发布不受影响,但是对于微模块来说只能够全量更新,无法实现灰度更新)
部分源码分析:
(ps:仓库放在最后了)
主要关注的有三个接口
1: getAllModules: 灰度平台使用: 获取的是所有的微模块信息
2: uploadModule: 脚本使用的,用来上传前端打包好的文件
3: getRemoteConfigure: 客户端使用,用来加载微模块的元数据
主要关注两个的是一个传一个拉
先说第二个接口传
目前我选择minio作为文件服务来存储上传的前端资源文件
原因相比较cepher较为方便,生态圈较友好,支持集群
uploadModule
上传的核心代码
func (g *GrayscaleModuleServive) UploadFiles(ctx context.Context, formData dataModels.FormData) (msg string, err error) {
// 解压文件拿到本地
tmpFile, err := ioutil.TempFile("", "uploadAssets-*.tar.gz")
if err != nil {
return err.Error(), err
}
defer os.Remove(tmpFile.Name())
zipFileByteArray, err := util.GetFileBytes(formData.Assets)
if err != nil {
return err.Error(), err
}
_, err = tmpFile.Write(zipFileByteArray)
if err != nil {
return err.Error(), err
}
tmpFile.Close()
// 临时解压目录
tmpDir, err := ioutil.TempDir("./", "uploadAssetsDir-*")
if err != nil {
return err.Error(), err
}
// 暂时先保留
defer os.RemoveAll(tmpDir)
// 拿到解压前的文件
tarFile, err := os.Open(tmpFile.Name())
if err != nil {
return err.Error(), err
}
defer tarFile.Close()
// 解压
gzReader, err := gzip.NewReader(tarFile)
if err != nil {
return err.Error(), err
}
defer gzReader.Close()
// 读取解压之后的文件流
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err.Error(), err
}
// 遍历如果是目录,新建目录
path := filepath.Join(tmpDir, header.Name)
if header.FileInfo().IsDir() {
if err := os.MkdirAll(path, os.ModePerm); err != nil {
return err.Error(), err
}
continue
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return err.Error(), err
}
defer file.Close()
if _, err := io.Copy(file, tarReader); err != nil {
return err.Error(), err
}
}
// 读取 hel-meta.json 文件 获取: bucketname, minioPrefix
helMetaPath := filepath.Join(tmpDir, "hel-meta.json")
content, err := ioutil.ReadFile(helMetaPath)
if err != nil {
return err.Error(), err
}
// 解析JSON对象
var metaDataJson dataModels.MetaData
err = json.Unmarshal(content, &metaDataJson)
if err != nil {
return err.Error(), err
}
// 上传之前需要先判断数据库是否有了该版本信息(有了就默认之前文件系统中已经上传了文件)
moduleInfo, err := g.grayscaleRepositories.GetModuleInfoByModuleName(metaDataJson.App.Name)
if err != nil {
return err.Error(), err
}
if moduleInfo.Id != 0 {
// 存在模块
versionInfo, err := g.grayscaleRepositories.GetVersionByVersonNameAndPid(moduleInfo.Id, metaDataJson.App.Version)
if err != nil {
return err.Error(), err
}
if versionInfo.Id != 0 {
// 已经存在了相同的版本,不能再上传了
return "已经有了相同的版本,请做版本升级", nil
}
}
// 1:先更新数据库,因为数据库上传有更多的判断
msg, err = g.AddModuleInfo(ctx, dataModels.ModuleVersionReq{ModuleName: metaDataJson.App.Name, IsUseValid: formData.IsUseValid, Version: metaDataJson.App.Version, IsStable: 1})
if err != nil {
return msg, err
}
// 2: 再更新静态文件服务
err = g.minioClient.UploadFolder(ctx, metaDataJson.App.Name, tmpDir, metaDataJson.App.Version)
if err != nil {
fmt.Println("UploadFolder--------------------", err)
return err.Error(), err
}
// 3: 也可以针对性的对本地数据做处理,再做重新上传
return "成功上传", nil
}
上面就是上传minio+ 更新上传信息的主要内容
其中要多判断一下的就是,相同版本无法重复上传
多提一嘴的是: minio在配置过程中,要支持相关的配置(这个百度即可),主要支持的是能够根据uri访问到文件数据即可,如果你可以直接访问到,那么忽略这个提醒
上面的上传是把文件流输出到服务端, 然后通过walk path ,把本地文件流对象存储到minio中
接下来就是详细的上传文件代码
func (m *Minio) UploadFolder(ctx context.Context, bucketName, folderPath, minioPrefix string) (err error) {
// Check if bucket exists and create if it doesn't exist
found, err := m.Client.BucketExists(ctx, bucketName)
if err != nil {
return err
}
if !found {
// 设置存储桶的访问控制列表 ACL
policy := fmt.Sprintf(`{
"Version":"2012-10-17",
"Statement":[
{
"Action":["s3:GetObject"],
"Effect":"Allow",
"Principal":{"AWS":["*"]},
"Resource":["arn:aws:s3:::%s/*"],
"Sid":"",
"Condition": {
"StringLike": {
"aws:Referer": "*"
}
}
}
]
}`, bucketName)
err = m.Client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
return err
}
err = m.Client.SetBucketPolicy(ctx, bucketName, policy)
if err != nil {
fmt.Println(err)
return
}
}
strArr := strings.Split(filepath.ToSlash(folderPath), "/")
fileName := strArr[1]
fmt.Println("fileName", fileName)
// Walk through local folder and upload all files
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("failed to walk through local folder: %v", err)
}
// Ignore directories
if info.IsDir() {
return nil
}
// Prepare object name in MinIO
objectName := filepath.ToSlash(strings.Replace(path, fileName, minioPrefix, 1))
var contentType string
// 获取文件扩展名
ext := filepath.Ext(path)
fmt.Println("walk: path:", ext)
if ext == "" {
// 如果扩展名为空,使用默认的MIME类型
contentType = "application/octet-stream"
} else {
// 根据扩展名获取MIME类型
contentType := mime.TypeByExtension(ext)
if contentType == "" {
// 如果无法获取MIME类型,使用默认的MIME类型
contentType = "application/octet-stream"
}
}
fmt.Println("walk: extesion:", contentType)
fmt.Println("work objectName", objectName)
// Upload object to MinIO
_, err = m.Client.FPutObject(ctx, bucketName, objectName, path, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return fmt.Errorf("failed to upload object %s to MinIO: %v", objectName, err)
}
return nil
})
return nil
}
getRemoteConfigure
然后是拉取最新的元数据
// 调用grpc获取获取账号下面的模块版本
func (g *GrayscaleModuleServive) GetRemoteConfigure(ctx context.Context, moduleName string, userId int64, isServer bool) (res interface{}, err error) {
//moduleLinkError := "xxxxx"
// 容错,如果数据库异常,以下逻辑走不通, 返回链接重定向到错误界面
//moduleLink = moduleLinkError
target := fmt.Sprintf("consul://%s/%s?wait=14s", g.consulAddress, "authority-service")
conn, err := grpc.Dial(
//consul网络必须是通的 user_srv表示服务 wait:超时 tag是consul的tag 可以不填
target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
//轮询法 必须这样写 grpc在向consul发起请求时会遵循轮询法
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy": "round_robin"}`),
)
if err != nil {
return "", err
}
defer conn.Close()
userSrvClient := pb.NewUserClient(conn)
rsp, err := userSrvClient.GetUserInfoByUserId(ctx, &pb.GetUserInfoByUserIdRequest{
Id: userId,
})
fmt.Println("gprc返回的数据", rsp)
if err != nil {
return "", err
}
// 根据moduleName 找到匹配的数据模块数据
moduleInfo, err := g.grayscaleRepositories.GetModuleInfoByModuleName(moduleName)
if err != nil {
return "", err
}
versionsInfo, err := g.grayscaleRepositories.GetAllversionUnderModule(moduleInfo.Id)
if err != nil {
return "", err
}
modulesInfoByUser := rsp.ModulesInfo
if len(modulesInfoByUser) != 0 {
fmt.Println("用户存在权限")
// 当前用户存在权限
moduleIdAndVersionIdList := strings.Split(modulesInfoByUser, "|")
moduleIdAndVersionIdListWithList := make([][]string, len(moduleIdAndVersionIdList))
for k, v := range moduleIdAndVersionIdList {
moduleIdAndVersionIdListWithList[k] = strings.Split(v, "-")
}
// 找到指定的版本号
var specifyVersionId string
for _, v := range moduleIdAndVersionIdListWithList {
// 说明用户端对于用户的版本信息录入有问题
if len(v) != 2 {
return
}
if v[0] == strconv.FormatInt(moduleInfo.Id, 10) {
specifyVersionId = v[1]
}
}
if len(specifyVersionId) != 0 {
for _, v := range versionsInfo {
if strconv.FormatInt(v.Id, 10) == specifyVersionId {
// 返回指定的版本
res, err = util.GetFromUnpkgOrFileServer(moduleName, v.Version, isServer)
if err != nil {
return nil, err
}
return res, nil
}
}
} else {
// 未找到用户端指定的权限版本,此时就当作用户没有权限
goto NoAuthexEcute
}
} else {
goto NoAuthexEcute
}
NoAuthexEcute:
{
fmt.Println("用户不存在权限或者没有找到权限")
var stableVersion string
var latestVersion string
for _, v := range versionsInfo {
if v.IsStable == 2 {
stableVersion = v.Version
}
if v.Id == moduleInfo.LatestVersionId {
latestVersion = v.Version
}
}
// 当前用户不存在权限, 默认采用: 1: 模块设置了使用稳定版本,使用稳定版本, 2:未设置使用稳定版本,使用最新版本
if moduleInfo.IsUseValid == 2 {
fmt.Println("无权限,使用了稳定版本", stableVersion)
// 使用了稳定版本
if len(stableVersion) != 0 {
// 如果找到了存在稳定版本
res, err = util.GetFromUnpkgOrFileServer(moduleName, stableVersion, isServer)
if err != nil {
return nil, err
}
return res, nil
} else {
// 虽然想使用稳定版本, 但是如果不存在稳定版本
res, err = util.GetFromUnpkgOrFileServer(moduleName, latestVersion, isServer)
if err != nil {
return nil, err
}
return res, nil
}
} else {
fmt.Println("无权限,使用了最新版本")
// 使用最新版本
if moduleInfo.LatestVersionId != 0 {
res, err = util.GetFromUnpkgOrFileServer(moduleName, latestVersion, isServer)
if err != nil {
return nil, err
}
return res, nil
}
}
}
fmt.Print("一定执行这个打印")
return
}
注意, 用户服务(登录,注册,获取用户信息等), 作为微服务我托管到了consul,当然这里你可以根据实际的情况做出改变, 最终目的是能够获取到该用户的微模块版本权限列表,也就是上面提到的 用户表中的modulesInfo字段,然后判断用户最终能够获取的版本是什么,具体流程不清楚的, 在文章的最后我会贴出流程图
最后明确完用户可以获取到的微模块的版本信息后,就是根据参数,来判断是从unpkg上获取,还是说从minio中获取
func GetFromUnpkgOrFileServer(moduleName string, version string, isServer bool) (res interface{}, err error) {
// unpkg: http://xxx.xx.xxx.xxx:18999/pc-com-test3@1.0.1/hel_dist/hel-meta.json
// file server: http://xxx.xx.xxx.xx:9000/pc-com-test3/1.0.0/hel-meta.json
var uri string
if isServer {
// 静态文件服务
uri = fmt.Sprintf("http://xxx.xx.xxx.xx:9000/%s/%s/hel-meta.json", moduleName, version)
} else {
// unpkg私服
uri = fmt.Sprintf("http://xxx.xx.xxx.xxx:18999/%s@%s/hel_dist/hel-meta.json", moduleName, version)
}
res, err = GetJSON(uri)
return
}
func GetFileBytes(file multipart.File) ([]byte, error) {
defer file.Close()
return ioutil.ReadAll(file)
}
func GetContentType(filename string) string {
return mime.TypeByExtension(filepath.Ext(filename))
}
其中为了优化获取元数据的加载速度,我通过redis做了数据缓存(做了之后,可以极大提到加载速度)
func GetJSON(url string) (interface{}, error) {
if RedisClient == nil {
fmt.Println("只初始化redis一次")
InitRedis()
}
// 从redis读取数据
cachedData, err := RedisClient.Get(context.Background(), url).Result()
if err == nil {
fmt.Println("读取了缓存数据")
// Data found in cache, unmarshal and return it
var data interface{}
if err := json.Unmarshal([]byte(cachedData), &data); err != nil {
return nil, err
}
return data, nil
} else {
fmt.Println("读取redis数据失败", err)
}
// 读取minio数据
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var data interface{}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return nil, err
}
// 缓存数据到redis中
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
if err := RedisClient.Set(context.Background(), url, jsonData, 0).Err(); err != nil {
return nil, err
}
return data, nil
}
上传的脚本(nodejs)
上传的脚本总结来说就是读取项目的package.json, 知道要上传的微模块名称和版本号, 然后把打包好的微模块资源压缩一下, 最后交给上文提到的服务器
这里的上传脚本我是放在了脚手架中加载
脚手架的文章是(附带源码):https://blog.youkuaiyun.com/weixin_42527937/article/details/137678225
其中的核心脚本代码是这个,因为比较简单就不多做解释了
pack管控平台设计
平台具体设计方案:
模块管理界面:
一级页面:
主要功能是指定微模块是否使用稳定版本, (getRemoteConfigure 这个接口会判断,如果用户指定了版本, 那么就使用选定的版本元数据,如果没有指定版本,会查看是否使用了稳定版本, 如果使用了稳定版本,就会使用稳定版本,否则就是用最新的版本)
二级页面:
针对模板,调用所有用户列表: 然后针对用户, 指定他能访问的版本吗,如果未指定: 那么就使用稳定版本,如果稳定版本也未指定,那么就使用最新版本
具体的简约界面设计图:
最后: 逻辑流程图
server 仓库: https://github.com/Colorssk/share-gray-services/tree/main
感谢阅读~~