开源限流组件分析(三):golang-time/rate

文章目录

  • 本系列
  • 前言
  • 提供获取令牌的API
  • 数据结构
  • 基础方法
    • tokensFromDuration
    • durationFromTokens
    • advance
  • 获取令牌方法
    • reverseN
    • 其他系列API
  • 令人费解的CancelAt
    • 是bug吗
  • 取消后无法唤醒其他请求

本系列

  • 开源限流组件分析(一):juju/ratelimit
  • 开源限流组件分析(二):uber-go/ratelimit
  • 开源限流组件分析(三):golang-time/rate (本文)

前言

根据开源限流组件分析(一):juju/ratelimit的分析,令牌桶限流的实现没必要真的准备一个桶,定时往里塞令牌,然后每次获取令牌时从桶中弹出,但这样做对内存不友好,需要开辟桶最大容量大小的空间

最佳做法是利用 token 数可以和时间跨度相互转化的原理,只用维护一个桶中当前令牌数的变量,每次消费前才根据时间差计算并更新 Token 数目

本文将分析go官方库提供的限流工具的使用及实现细节,也是令牌桶算法。并对下面这个问题给出合理的解释:在CancelAt方法中,为啥归还令牌时不是归还所有令牌,而是要扣减一个值

本文基于源码:https://github.com/golang/time.git,版本:v0.7.0

提供获取令牌的API

  • Wait/WaitN:当没有足够的Token时,将在内部阻塞等待直到Token足够,或超时取消

    • 工程中推荐使用该方法,参数中有ctx,和ctx配合能优雅实现超时控制
  • Allow/AllowN:当前时刻没有足够的Token时,返回false。否则获取到令牌,返回成功

  • Reverse/ReverseN:当没有足够的Token时,返回 Reservation 对象,里面包含是否获取成功,以及要等多久才能放行请求

    • 可控性最好,可以选择等待到规定的时间然后放心请求,也可以调Reservation.Cancel() 取消等待,归还令牌

数据结构

type Limit float64

type Limiter struct {
	mu sync.Mutex

	// 每秒产生多少个token
	limit Limit
	// 桶大小
	burst int
	// 桶中剩余的token数,可以为负数,表示被某些请求预定了一些未来的令牌
	tokens float64
	// 最近一次推进令牌桶的时间
	last time.Time
	// 令牌可以满足最近一次请求的时间
	lastEvent time.Time
}

特别说明这两个字段:

  • last:表示最近一次推进令牌桶的时间,也就是每次调获取令牌api的时间,因此每次获取令牌都会推进令牌桶的时间

    • 这样每次获取令牌时,都会看从last到now这段时间之间产生了多少新的令牌
  • lastEvent:表示令牌桶中最近一次请求能放行的时间。如果当前时间调获取令牌api时就能满足,就是当前时间,否则是未来的某个时间

    • 只用于CancelAt计算归还多少个令牌,可以到后面再理解

基础方法

tokensFromDuration

计算在时长d内,可以生成多少个令牌

也就是 速度 * 时间 = 总量

func (limit Limit) tokensFromDuration(d time.Duration) float64 {
   if limit <= 0 {
      return 0
   }
   return d.Seconds() * float64(limit)
}

durationFromTokens

计算生成tokens个令牌,需要多少时间

也就是 总数 / 速度 = 时间

func (limit Limit) durationFromTokens(tokens float64) time.Duration {
	if limit <= 0 {
		return InfDuration
	}
	seconds := tokens / float64(limit)
	return time.Duration(float64(time.Second) * seconds)
}

advance

计算如果时间推进到t后,桶中可用的令牌个数

注意:该方法只计算,不会更新桶中的令牌数

func (lim *Limiter) advance(t time.Time) (newT time.Time, newTokens float64) {
	last := lim.last
	if t.Before(last) {
		last = t
	}

    // 过去了多长时间
	elapsed := t.Sub(last)
	// 计算过去这段时间,一共产生多少token
	delta := lim.limit.tokensFromDuration(elapsed)
	tokens := lim.tokens + delta
	// tokens不能超过最大容量
	if burst := float64(lim.burst); tokens > burst {
		tokens = burst
	}
	return t, tokens
}

获取令牌方法

reverseN

Wait系列,Allow系列,Reserve系列的方法,底层都调了reverseN,其流程如下:

  1. 计算如果时间推进到now,桶中可用的 token 数

  2. 计算本次消费后,桶还能剩能下多少token

  3. 如果 token < 0, 说明目前的token不够,需要等待一段时间

  4. 只有同时满足下面两个条件时,才获取令牌成功

    1. n <= lim.burst:申请的token数量没有超过了桶的容量大小
    2. waitDuration <= maxFutureReserve: 需要等待的时间 <=用户期望的时间

注意如果桶中令牌数不够,需要等待直到桶中令牌产生为止

此时需要将桶中令牌数置为负值,表示有请求欠了账,占了位

如果接下来有别的请求来获取令牌,会在之前欠账的基础上继续欠账,让令牌数的负值更大,需要等待更长的时间

通过时间流逝来还这些债,这一点和另一个令牌桶开源限流组件分析(一):juju/ratelimit很类似

// 当前时t时刻,想获取n个令牌,最多等maxFutureReserve时间
func (lim *Limiter) reserveN(t time.Time, n int, maxFutureReserve time.Duration) Reservation {
	lim.mu.Lock()
	defer lim.mu.Unlock()

    // 可以忽略这个分支,因为用了限流器就肯定要有速率限制
	if lim.limit == Inf {
		return Reservation{
			ok:        true,
			lim:       lim,
			tokens:    n,
			timeToAct: t,
		}
	}

	// 如果时间推进到now,可用的 token 数
	t, tokens := lim.advance(t)

	// 本次消费后,桶还能剩能下多少token
	tokens -= float64(n)

	// 如果 token < 0, 说明目前的token不够,需要等待一段时间
	var waitDuration time.Duration
	if tokens < 0 {
		waitDuration = lim.limit.durationFromTokens(-tokens)
	}

	// n <= lim.burst:申请的 token 没有超过了桶的大小
	// waitDuration <= maxFutureReserve: 需要等待的时间 <=用户期望的时间
	ok := n <= lim.burst && waitDuration <= maxFutureReserve

	r := Reservation{
		ok:    ok,
		lim:   lim,
		limit: lim.limit,
	}

	// 能获取到令牌
	if ok {
        // 本次获取了多少token
		r.tokens = n
		// 表示需要等待到这个时刻才能获得期望数量的token(当然 waitDuration 有可能为 0,就是立即满足,timeToAct就是now)
		r.timeToAct = t.Add(waitDuration)

		// 更新推进令牌桶的时间
		lim.last = t
		// 就扣减令牌,更新桶中token的数量
		lim.tokens = tokens
		// 桶中可以满足最近一次请求的时间
		lim.lastEvent = r.timeToAct
	}

	return r
}

其他系列API

allowN:调reserveN时传maxFutureReserve=0,表示只有不等待就能获得时,才获取令牌

func (lim *Limiter) AllowN(t time.Time, n int) bool {
	return lim.reserveN(t, n, 0).ok
}

ReserveN:直接调reserveN

func (lim *Limiter) ReserveN(t time.Time, n int) *Reservation {
   r := lim.reserveN(t, n, InfDuration)
   return &r
}

WaitN:根据ctx计算要获取令牌最多等待多久,并以这个时间最为最大等待时间调reverseN。然后在内部sleep

  1. 先检查ctx是否已取消,如果已取消直接返回

  2. 根据ctx还剩多久过期,来决定在reverseN中最多等待多久

    1. 换句话说,如果判断直到ctx过期都无法获取令牌,就不等了
  3. 获取到Reservation后,看 1)根据Reservation计算还有多久能获取令牌的时间delay,和2) ctx被取消,这两件事谁先发生:

    1. 如果delay先过完:获取令牌成功,可以执行请求
    2. 如果ctx先被取消:本次获取令牌失败,需要尽可能归还桶中的令牌

正常来说,只要不是外部主动取消,都是delay先过完

func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) {
    // 其实就是:time.NewTimer(d)
	newTimer := func(d time.Duration) (<-chan time.Time, func() bool, func()) {
		timer := time.NewTimer(d)
		return timer.C, timer.Stop, func() {}
	}

	return lim.wait(ctx, n, time.Now(), newTimer)
}


func (lim *Limiter) wait(ctx context.Context, n int, t time.Time, newTimer func(d time.Duration) (<-chan time.Time, func() bool, func())) error {
	lim.mu.Lock()
	burst := lim.burst
	limit := lim.limit
	lim.mu.Unlock()

    // 不能超过桶容量
	if n > burst && limit != Inf {
		return fmt.Errorf("rate: Wait(n=%d) exceeds limiter's burst %d", n, burst)
	}

    // 先检查下ctx是否已取消
  select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	// 获取令牌的最大等待时间根据ctx计算
	waitLimit := InfDuration
	if deadline, ok := ctx.Deadline(); ok {
		waitLimit = deadline.Sub(t)
	}

	r := lim.reserveN(t, n, waitLimit)
	if !r.ok {
		return fmt.Errorf("rate: Wait(n=%d) would exceed context deadline", n)
	}

    // 该Reservation还需要等多久才能获得令牌
    delay := r.DelayFrom(t)
	if delay == 0 {
		return nil
	}
  
	ch, stop, _ := newTimer(delay)
	defer stop()
	select {
	case <-ch:
		// ctx超时或被取消之前,就获得令牌了
		return nil
	case <-ctx.Done():

		// 在获得令牌之前,ctx就被取消了,需要归还令牌到桶中
		r.Cancel()
		return ctx.Err()
	}
}

令人费解的CancelAt

最后看看CancelAt:取消本次持有,尽可能归还持有的令牌

为啥需要归还令牌?因此本次实际上没有放行请求,而是被cancel掉了,可以把令牌放回桶中,供接下来的请求使用

// 参数t一般是当前时间
func (r *Reservation) CancelAt(t time.Time) {
	if !r.ok {
		return
	}

	r.lim.mu.Lock()
	defer r.lim.mu.Unlock()

	/**
	1.如果产生令牌的速率无限大,那没必要归还令牌
	2.自己没有拿到token,也不用归还
	3.预期获得时间比当前时间早,这里认为不用归还
	 */
	if r.lim.limit == Inf || r.tokens == 0 || r.timeToAct.Before(t) {
		return
	}

	// 如果只有有更新的请求请求了令牌,要归还的令牌数减去
	// r.lim.lastEvent.Sub(r.timeToAct) 这期间产生的令牌
	restoreTokens := float64(r.tokens) - r.limit.tokensFromDuration(r.lim.lastEvent.Sub(r.timeToAct))
	if restoreTokens <= 0 {
		return
	}

	// 推进桶的时间
	t, tokens := r.lim.advance(t)

	tokens += restoreTokens
	if burst := float64(r.lim.burst); tokens > burst {
		tokens = burst
	}

	r.lim.last = t
	// 归还token
	r.lim.tokens = tokens

	// 如果相等,说明后面没有新的token消费
	if r.timeToAct == r.lim.lastEvent {
		// 上一次的lastEvent
		prevEvent := r.timeToAct.Add(r.limit.durationFromTokens(float64(-r.tokens)))
		if !prevEvent.Before(t) {
			// 恢复lastEvent为上一次的lastEvent
			r.lim.lastEvent = prevEvent
		}
	}
}

是bug吗

其中比较费解的是这一行:为啥要多减去这一部分

restoreTokens := float64(r.tokens) - r.limit.tokensFromDuration(r.lim.lastEvent.Sub(r.timeToAct))
  • r.TimeToAct:本次Reservation可以放行请求的时间,也是能使用令牌的时间
  • r.lim.lastEvent:令牌桶中,最近一次请求的TimeToAct

在归还令牌时,要减掉这段时间范围能产生的令牌数

对应于下面这个场景:

在这里插入图片描述

这行代码有注释:

// calculate tokens to restore
// The duration between lim.lastEvent and r.timeToAct tells us how many tokens were reserved
// after r was obtained. These tokens should not be restored.

但只是解释了在干啥:在r之后获取的令牌不应该被重新放回桶中

而没有解释为啥要这么干


有人在stackoverflow提问,归还所有持有的token是否应该改成restoreTokens = float64(r.tokens)

在这里插入图片描述

也有人在官方仓库提issue,举了这样一个例子:

func TestLimit(t *testing.T) {
	t0 := time.Now()
	t1 := time.Now().Add(100 * time.Millisecond)
	t2 := time.Now().Add(200 * time.Millisecond)
	t3 := time.Now().Add(300 * time.Millisecond)
    // 桶每秒产生10个令牌,容量=20
	l := NewLimiter(10, 20)
	l.ReserveN(t0, 15) 
    // 桶里还剩 5 个 token
	fmt.Printf("%+v\n", l)

	// t1:时间过去100ms,桶里增加1个,变成6个
	// 消费10个,变成-4个
	r := l.ReserveN(t1, 10)
	// 此时timeToAct:500ms
	fmt.Printf("%+v\n", l)

	// t2:时间过去100ms,桶里增加1个,变成-3个
	// 消费2个, 桶里变成-5个
	l.ReserveN(t2, 2)
	// 此时timeToAct:700ms
	fmt.Printf("%+v\n", l)

	// t3: 时间过去100ms,桶里增加1个,变成-4个
	// r在t3取消,变成4(归还8个)个,按常理是变成6(归还10个)个
	r.CancelAt(t3)
	fmt.Printf("%+v\n", l)
}

最后一行:

  • 按常理来说,应该把所有令牌都归还,也就是归还10个,这样桶中的令牌数变成6
  • 实际上只归还了8个,桶中的令牌数变成4。就是那行令人费解的代码造成的

有人在issue下面给出解释:

在这里插入图片描述

意思就是:如果把保留的令牌数全部还回去的话,会造成在某一时刻突破令牌桶的速率限制

拿上面例子说明:

如果在t3时刻取消r时,如果把所有的令牌都归还了,也就是归还10个,此时桶中的令牌数变成6

那么再过400ms,在t2时刻获取的Reservation r2就可以开始执行,同时现在桶中有10个令牌!

如果此时有请求来获取这10个令牌,算上刚刚开始执行的r2,那就相当于同时有加起来需要消耗12个令牌的请求在执行!超过了最大容量10的限制


而按照源码中的逻辑扣减,在t3时刻取消r时,只归还8个,此时桶中的令牌数变成4

那么再过400ms,在t2时刻获取的Reservation r2就可以开始执行,同时现在桶中有8个令牌

如果此时有请求来获取这8个令牌,算上刚刚开始执行的r2,那就会同时加起来需要消耗10个令牌的请求在执行,刚好不超过桶中的最大容量的限制

也就是说,如果r后面有更新的请求rnew,rnew.TimeToAct赋值给了r.limit.lastEvent

那么从r.TimeToActr.limit.lastEvent之间能产生的令牌是不能归还的,而是要等时间流逝自然填充

这样当rnew可以执行时,桶中的令牌数 + rnew获取的令牌数才不会超过桶的容量


取消后无法唤醒其他请求

这个库有个小问题:如果请求A在请求B之前发生,都需要等待一段时间。如果任务A提前cancel了,使得桶中的令牌满足请求B的需求,请求B也不会被唤醒,而是等待其预定的时间到了再执行

例如下面的例子:

func TestBug(t *testing.T) {
	// 每100ms产生一个令牌,最大容量=10
	l := NewLimiter(10, 10)
	t1 := time.Now()
	// 先把桶中的初始令牌用完
	l.ReserveN(t1, 10)

	var wg sync.WaitGroup

	ctx, cancel := context.WithTimeout(context.TODO(), time.Hour)
	defer cancel()

	wg.Add(1)

	wg.Add(2)
	go func() {
		defer wg.Done()
		// 如果要等,这个要等 1s才能执行,但是我们的 ctx 200ms 就会取消
		l.WaitN(ctx, 10)
		fmt.Printf("[1] cost: %s\n", time.Since(t1))
	}()
	go func() {
		defer wg.Done()
		// 模拟出现问题, 200ms就取消了
		time.Sleep(200 * time.Millisecond)
		cancel()
	}()

	time.Sleep(100 * time.Millisecond)

	go func() {
		defer wg.Done()
		// 正常情况下,这个要等 1.2 s 才能执行,
		// 但是我们前面都取消了,按理说在400ms时,桶中令牌数就够了,这个可以执行
		// 但实际上没有唤醒,等到1.2才执行
		ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
		defer cancel()
		l.WaitN(ctx, 2)
		fmt.Printf("[2] cost: %s\n", time.Since(t1))
	}()

	wg.Wait()
}
  1. 时刻0ms:请求A获取10个token,需要等1000ms,桶中tokens = -10
  2. 时刻100ms:请求B获取2个token,需要等1200ms,桶中tokens = -11
  3. 时刻200ms:请求A取消,往桶中归还8个token,桶中tokens = -2
  4. 时刻400ms:理想情况下,此时请求B可以执行,因为桶中容量够了
  5. 时刻1.2s:实际上,请求B此时才可以执行

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/900515.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Apache Paimon Catalog

Paimon Catalog可以持久化元数据&#xff0c;当前支持两种类型的metastore&#xff1a; 文件系统&#xff08;默认&#xff09;&#xff1a;将元数据和表文件存储在文件系统中。hive&#xff1a;在 hive metastore中存储元数据。用户可以直接从 Hive 访问表。 2.2.1 文件系统…

分布式IO模拟量模块:多领域应用的高效能解决方案

分布式IO模拟量模块是分布式IO系统中的重要组件&#xff0c;用于实现现场设备或过程的模拟量信号的采集、监视和控制。该模块通常与现场总线耦合器配合使用&#xff0c;能够接收来自现场设备的模拟量信号&#xff08;如电流、电压等&#xff09;&#xff0c;并将其转换为数字信…

STM32-Cube定时器TIM

一、内部时钟源 1、创建项目 File → New → STM32 project选择STM32F103C8T6单片机&#xff0c;命名TIM 2、配置单片机 1.打开USART1&#xff0c;方便我们与电脑连接查看数据 开启UART1并开启中断。 2、设置时钟源 开启外部高速晶振 将时钟频率设置为72MHz 设置调试模…

利用飞腾派进行OpenCV开发

实验目标&#xff1a; 完成飞腾平台OpenCV开发。 实验大纲&#xff1a; Mat数据结构加载、显示、保存图像读写像素RGB图像分离彩色图转灰度图 Mat数据结构 Mat是一个类&#xff0c;由两个数据部分组成&#xff1a;矩阵头(大小,通道,数据类型等)和数据块(像素 值)。创建示例…

uniapp uview 上传图片,数据以formData + File 形式传输

期望 后端期望前端给的传参为 formData 形式, 同时文件的数据类型为File 形式. 解决过程 将文件处理为 File 格式 uview 中的 upload 组件点击上传之后不是标准的 File 形式,点击上传单个文件之后的控制台信息如下: [{"url": "blob:http://localhost:8081/…

华为云实战杂记

配置nginx服务器 首先我们拿到一台服务器时&#xff0c;并不知道系统是否存在Nginx我们可以在Linux命令行执行如下命令查看 find / -name nginx* find / -name nginx* 查找所有名字以nginx开头的文件或者目录&#xff0c;我们看看系统里面都有哪些文件先&#xff0c;这样可以快…

【Jenkins】解决在Jenkins Agent节点容器内无法访问物理机的docker和docker compose的问题

解决在Jenkins Agent节点容器内无法访问物理机的docker和docker compose的问题 1. 确定物理机docker和docker compose已经安装2. 编写Jenkins Agent结点docker-compose.yaml配置文件3. 修改docker运行时文件权限4. 启动容器并验证 最近接触到一个发布产物是一个 docker镜像的项…

【力扣】GO解决子序列相关问题

文章目录 一、引言二、动态规划方法论深度提炼子序列问题的通用解法模式 三、通用方法论应用示例&#xff1a;最长递增子序列&#xff08;LeetCode题目300&#xff09;Go 语言代码实现 四、最长连续递增序列&#xff08;LeetCode题目674&#xff09;Go 语言代码实现 五、最长重…

EXCELL中如何两条线画入一张图中,标记坐标轴标题?

1&#xff0c;打开excel&#xff0c;左击选中两列&#xff0c; 2&#xff0c;菜单栏>“插入”>”二维折线图”选中一个 3&#xff0c;选中出现的两条线中的一条右击>最下一行&#xff0c;“设置数据系列格式” 4&#xff0c;右测“系列选项中”>点击“次坐标轴” 5…

邮件系统SSL加密传输,保护你的电子邮件免受网络威胁

在互联网的浪潮中&#xff0c;企业数字化转型的步伐不断加快。企业邮箱作为数字化应用的重要组成部分&#xff0c;已成为员工沟通、协同工作和企业管理的关键工具。但是在公共网络安全性普遍较弱的背景下&#xff0c;黑客容易侵入企业网络&#xff0c;监控流量&#xff0c;截获…

私域朋友圈运营

今天必须给大家分享一份超棒的朋友圈运营思维导图 有了它&#xff0c;你可以逐步打造属于自己的精彩朋友圈&#x1f389;。无论是想分享生活点滴&#x1f4a7;&#xff0c;还是展示个人魅力✨&#xff0c;又或者推广自己的业务&#x1f4c8;&#xff0c;这份思维导图都能给你指…

vuetify学习笔记(v-app和v-responsive)

我最近在学习vuetify3&#xff0c;我以前是用element plus和taiwind css。vuetify的一个好处是&#xff0c;它不仅是一个向element plus一样提供好用的组件库&#xff0c;而且还提供了向taiwind css一样的原子类&#xff0c;可以通过类名方便的定义组建的样式。以前element plu…

为什么使用 toFixed 方法的结果不一致呢?

偶遇一个不准确的方法 toFixed() &#xff0c;其是 JS 中用于将数字格式化为指定小数位数的方法&#xff0c;但有时返回的结果不够准确&#xff0c;展示如下&#xff1a; 这通常是由于 JavaScript 对浮点数的处理方式导致的。 1. 浮点数精度问题 JavaScript 中的数字是以 IEE…

乡村小道图像分割系统:智能化检测

乡村小道图像分割系统源码&#xff06;数据集分享 [yolov8-seg-C2f-Faster-EMA&#xff06;yolov8-seg-HGNetV2等50全套改进创新点发刊_一键训练教程_Web前端展示] 1.研究背景与意义 项目参考ILSVRC ImageNet Large Scale Visual Recognition Challenge 项目来源AAAI Globa…

JavaWeb合集22-Apache POI

二十二、Apache POI Apache POI是一个处理Miscrosoft Office各种文件格式的开源项目。简单来说就是&#xff0c;我们可以使用POI在Java 序中对Miscrosoft Office各种文件进行读写操作。一般情况下&#xff0c;POI都是用于操作Excel文件。 使用场景&#xff1a;银行网银系统导出…

Unity Vision Pro 保姆级开发教程-PolySpatial VisionOS Samples 示例场景

视频教程地址 PolySpatial VisionOS Samples 示例场景 Unity Vision Pro 中文课堂教程地址&#xff1a; Unity3D Vision Pro 开发教程【保姆级】 | Unity 中文课堂 有界体积样本 Balloon Gallery 气球画廊 气球画廊是一个迷你游戏&#xff0c;演示了使用Indirect Pinch and …

vue3使用i18n做国际化多语言,实现常量跟随语言切换翻译

因为我有一个常量的配置文件在项目中&#xff0c;而且有中文内容&#xff0c;我想在切换语言的时候&#xff0c;跟着这个翻译也实时切换&#xff0c;就可以使用computed计算属性实现。 把name改成下面的样子&#xff1a; name: computed(() > t(pad.regularMode)), 就可以…

基于SpringBoot的人才公寓管理系统

作者&#xff1a;计算机学姐 开发技术&#xff1a;SpringBoot、SSM、Vue、MySQL、JSP、ElementUI、Python、小程序等&#xff0c;“文末源码”。 专栏推荐&#xff1a;前后端分离项目源码、SpringBoot项目源码、Vue项目源码、SSM项目源码、微信小程序源码 精品专栏&#xff1a;…

一个包含了超过 200 个实用脚本的 Python 脚本库,如文件管理、网络操作、图像处理、文本处理等

前言 在日常的工作和生活中&#xff0c;我们经常会遇到一些重复性的任务&#xff0c;如文件管理、网络cao作、图像处理、文本处理等。这些任务虽然简单&#xff0c;但如果频繁手动cao作&#xff0c;不仅耗时耗力&#xff0c;还容易出错。 现有的软件虽然能处理一部分问题&…

Vue2+Univer 环境搭建

node js 版本 16.32 参考文档&#xff1a; Vue2Univer实现可编辑Excel_vue univer-CSDN博客 https://univer.ai/guides/sheet/getting-started/quickstart 实现步骤&#xff1a; 1、包里面直接写这些 "riophae/vue-treeselect": "0.4.0","univ…