微模块(热重载微模块的基建,附源码)

前提铺垫:

说到微前端,也许大家想到很多,比如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

感谢阅读~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值