2024年10月

逃逸分析算是go语言的特色之一,编译器自动分析变量/内存应该分配在栈上还是堆上,程序员不需要主动关心这些事情,保证了内存安全的同时也减轻了程序员的负担。

然而这个“减轻负担”的特性现在却成了程序员的心智负担。尤其是各路八股文普及之后,逃逸分析相关的问题在面试里出现的频率越来越高,不会往往意味着和工作机会失之交臂,更有甚者会认为不了解逃逸分析约等于不会go。

我很不喜欢这些现象,不是因为我不会go,而是我知道逃逸分析是个啥情况:分析规则有版本间差异、规则过于保守很多时候把可以在栈上的变量逃逸到堆上、规则繁杂导致有很多corner case等等。更不提有些质量欠佳的八股在逃逸分析的描述上还有误导了。

所以我建议大部分人回归逃逸分析的初心——对于程序员来说逃逸分析应该就像是透明的,不要过度关心它。

怎么知道变量是不是逃逸了

我还见过一些比背过时的八股文更过分的情况:一群人围着一段光秃秃的代码就变量到底会不会逃逸争得面红耳赤。

他们甚至没有用go编译器自带的验证方法来论证自己的观点。

那样的争论是没有意义的,你应该用下面的命令来检查编译器逃逸分析的结果:

$ go build -gcflags=-m=2 a.go

# command-line-arguments
./a.go:5:6: cannot inline main: function too complex: cost 104 exceeds budget 80
./a.go:12:20: inlining call to fmt.Println
./a.go:12:21: num escapes to heap:
./a.go:12:21:   flow: {storage for ... argument} = &{storage for num}:
./a.go:12:21:     from num (spill) at ./a.go:12:21
./a.go:12:21:     from ... argument (slice-literal-element) at ./a.go:12:20
./a.go:12:21:   flow: fmt.a = &{storage for ... argument}:
./a.go:12:21:     from ... argument (spill) at ./a.go:12:20
./a.go:12:21:     from fmt.a := ... argument (assign-pair) at ./a.go:12:20
./a.go:12:21:   flow: {heap} = *fmt.a:
./a.go:12:21:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./a.go:12:20
./a.go:7:19: make([]int, 10) does not escape
./a.go:12:20: ... argument does not escape
./a.go:12:21: num escapes to heap

哪些东西逃逸了哪些没有显示得一清二楚——
escapes to heap
表示变量或表达式逃逸了,
does not escape
则表示没有发生逃逸。

另外本文讨论的是go官方的gc编译器,像一些第三方编译器比如tinygo没义务也没理由使用和官方完全相同的逃逸规则——这些规则并不是标准的一部分也不适用于某些特殊场景。

本文的go版本是1.23,我也不希望未来某一天有人用1.1x或者1.3x版本的编译器来问我为啥实验结果不一样了。

八股文里的问题

先声明,对事不对人,愿意分享信息的精神还是值得尊敬的。

不过分享之前至少先做点简单的验证,不然那些倒果为因还有胡言乱语的内容就止增笑耳了。

编译期不知道大小的东西会逃逸

这话其实没说错,但很多八股文要么到这里结束了,要么给出一个很多时候其实不逃逸的例子然后做一大通令人捧腹的解释。

比如:

package main

import "fmt"

type S struct {}

func (*S) String() string { return "hello" }

type Stringer interface {
        String() string
}

func getString(s Stringer) string {
        if s == nil {
                return "<nil>"
        }
        return s.String()
}

func main() {
        s := &S{}
        str := getString(s)
        fmt.Println(str)
}

一些八股文会说
getString
的参数s在编译期很难知道实际类型是什么,所以大小不好确定,所以会导致传给它的参数逃逸。

这话对吗?对也不对,因为编译期这个时间段太宽泛了,一个interface在“编译期”的前半段时间不知道实际类型,但后半段就有可能知道了。所以关键在于逃逸分析在什么时候进行,这直接决定了类型为接口的变量的逃逸分析结果。

我们验证一下:

# command-line-arguments
...
./b.go:22:18: inlining call to getString
...
./b.go:22:18: devirtualizing s.String to *S
...
./b.go:23:21: str escapes to heap:
./b.go:23:21:   flow: {storage for ... argument} = &{storage for str}:
./b.go:23:21:     from str (spill) at ./b.go:23:21
./b.go:23:21:     from ... argument (slice-literal-element) at ./b.go:23:20
./b.go:23:21:   flow: fmt.a = &{storage for ... argument}:
./b.go:23:21:     from ... argument (spill) at ./b.go:23:20
./b.go:23:21:     from fmt.a := ... argument (assign-pair) at ./b.go:23:20
./b.go:23:21:   flow: {heap} = *fmt.a:
./b.go:23:21:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./b.go:23:20
./b.go:21:14: &S{} does not escape
./b.go:23:20: ... argument does not escape
./b.go:23:21: str escapes to heap

我只截取了关键信息,否则杂音太大。
&S{} does not escape
这句直接告诉我们
getString
的参数并没有逃逸。

为啥?因为
getString
被内联了,内联后编译器发现参数的实际类型就是S,所以
devirtualizing s.String to *S
做了去虚拟化,这下接口的实际类型编译器知道了,所以没有让参数逃逸的必要了。

而str逃逸了,str的类型是已知的,内容也是常量字符串,按八股文的理论不是不应该逃逸么?其实上面的信息也告诉你为什么了,因为
fmt.Println
内部的一些函数没法内联,而它们又用any去接受参数,这时候编译器没法做去虚拟化,没法最终确定变量的真实大小,所以str只能逃逸了。记得最开头我说的吗,逃逸分析是很保守的,因为内存安全和程序的正确性是第一位的。

如果禁止函数inline,情况就不同了,我们在go里可以手动禁止一个函数被内联:

+//go:noinline
func getString(s Stringer) string {
        if s == nil {
                return "<nil>"
        }
        return s.String()
}

这回再看结果:

# command-line-arguments
./b.go:14:6: cannot inline getString: marked go:noinline
...
./b.go:22:14: &S{} escapes to heap:
./b.go:22:14:   flow: s = &{storage for &S{}}:
./b.go:22:14:     from &S{} (spill) at ./b.go:22:14
./b.go:22:14:     from s := &S{} (assign) at ./b.go:22:11
./b.go:22:14:   flow: {heap} = s:
./b.go:22:14:     from s (interface-converted) at ./b.go:23:19
./b.go:22:14:     from getString(s) (call parameter) at ./b.go:23:18
./b.go:22:14: &S{} escapes to heap
./b.go:24:20: ... argument does not escape
./b.go:24:21: str escapes to heap

getString
没法内联,所以没法做去虚拟化,最后无法在逃逸分析前得知变量的大小,所以作为参数的s最后逃逸了。

因此“编译期”这个表述不太对,正确的应该是“
在逃逸分析执行时不能知道确切大小的变量/内存分配会逃逸
”。还有一点要注意:
内联和一部分内置函数/语句的改写发生在逃逸分析之前
。内联是什么大家应该知道,改写改天有空了再好好介绍。

而且go对于什么能在逃逸分析前计算出来也是比较随性的:

func main() {
        arr := [4]int{}
        slice := make([]int, 4)
        s1 := make([]int, len(arr)) // not escape
        s2 := make([]int, len(slice)) // escape
}

s1不逃逸但s2逃逸,因为len在计算数组的长度时会直接返回一个编译期常量。而len计算slice的长度时并不能在编译期完成计算,所以即使我们很清楚slice此时的长度就是4,但go还是会认为s2的大小不能在逃逸分析前就确定。

这也是为什么我告诫大家不要过度关心逃逸分析这东西,很多时候它是反常识的。

编译期知道大小就不会逃逸吗

有的八股文基于上一节的现象,得出了下面这样的结论:
make([]T, 常数)
不会逃逸。

我觉得一个合格的go或者c/c++/rust程序员应该马上近乎本能地反驳:不逃逸就会分配在栈上,栈空间通常有限(系统栈通常8-10M,goroutine则是固定的1G),如果这个make需要的内存空间大小超过了栈的上限呢?

很显然超过了上限就会逃逸到堆上,所以上面那句不太对。go当然有规定一次在栈空间上分配内存的上限,这个上限也远小于栈大小的上限,但我不会告诉你是多少,因为没人保证以后不会改,而且我说了,你关心这个并没有什么用。

还有一种经典的情况,make生成的内容做返回值:

func f1() []int {
        return make([]int, 64)
}

逃逸分析会给出这样的结果:

# command-line-arguments
...
./c.go:6:13: make([]int, 64) escapes to heap:
./c.go:6:13:   flow: ~r0 = &{storage for make([]int, 64)}:
./c.go:6:13:     from make([]int, 64) (spill) at ./c.go:6:13
./c.go:6:13:     from return make([]int, 64) (return) at ./c.go:6:2
./c.go:6:13: make([]int, 64) escapes to heap

这没什么好意外的,因为返回值要在函数调用结束后继续被使用,所以它只能在堆上分配。这也是逃逸分析的初衷。

不过因为这个函数太简单了,所以总是能内联,一旦内联,这个make就不再是返回值,所以编译器有机会不让它逃逸。你可以用上一节教的
//go:noinline
试试。

slice的元素数量和是否逃逸关系不大

还有的八股会这么说:“slice里的元素数量太多会导致逃逸”,还有些八股文还会信誓旦旦地说这个数量限制是什么10000、十万。

那好,我们看个例子:

package main

import "fmt"

func main() {
        a := make([]int64, 10001)
        b := make([]byte, 10001)
        fmt.Println(len(a), len(b))
}

分析结果:

...
./c.go:6:11: make([]int64, 10001) escapes to heap:
./c.go:6:11:   flow: {heap} = &{storage for make([]int64, 10001)}:
./c.go:6:11:     from make([]int64, 10001) (too large for stack) at ./c.go:6:11
...
./c.go:6:11: make([]int64, 10001) escapes to heap
./c.go:7:11: make([]byte, 10001) does not escape
...

怎么元素数量一样,一个逃逸了一个没有?说明了和元素数量就没关系,只和上一节说的栈上对内存分配大小有限制,超过了才会逃逸,没超过你分配一亿个元素都行。

关键是这种无聊的问题出镜率还不低,我和我朋友都遇到过这种:

make([]int, 10001)

就问你这个东西逃逸不逃逸,面试官估计忘了int长度不是固定的,32位系统上它是4字节,64位上是8字节,所以没有更多信息之前这个问题没法回答,你就是把Rob Pike抓来他也只能摇头。面试遇到了还能和面试官掰扯掰扯,笔试遇到了你怎么办?

这就是我说的倒果为因,slice和数组会逃逸不是因为元素数量多,而是消耗的内存(元素大小x数量)超过了规定的上限。

new和make在逃逸分析时几乎没区别

有的八股文还说new的对象经常逃逸而make不会,所以应该尽量少用new。

这是篇老八股了,现在估计没人会看,然而就算在当时这句话也是错的。我想大概是八股作者不经验证就把Java/c++里的知识嫁接过来了。

我得澄清一下,new和make确实非常不同,但只不同在两个地方:

  1. new(T)
    返回*T,而
    make(T, ...)
    返回T
  2. new(T)
    中T可以是任意类型(但slice呀接口什么的一般不建议),而
    make(T, ...)
    的T只能是slice、map或者chan。

就这两个,另外针对slice之类的东西它们在初始化的具体方式上有一点区别,但这勉强包含在第二点里了。

所以绝不会出现new更容易导致逃逸,new和make一样,会不会逃逸只受大小限制以及可达性的影响。

看个例子:

package main

import "fmt"

func f(i int) int {
        ret := new(int)
        *ret = 1
        for j := 1; j <= i; j++ {
                *ret *= j
        }
        return *ret
}

func main() {
        num := f(5)
        fmt.Println(num)
}

结果:

./c.go:5:6: can inline f with cost 20 as: func(int) int { ret := new(int); *ret = 1; for loop; return *ret }
...
./c.go:15:10: inlining call to f
./c.go:16:13: inlining call to fmt.Println
./c.go:6:12: new(int) does not escape
...
./c.go:15:10: new(int) does not escape
./c.go:16:13: ... argument does not escape
./c.go:16:14: num escapes to heap

看到
new(int) does not escape
了吗,流言不攻自破。

不过为了防止有人较真,我得稍微介绍一点实现细节:虽然new和make在逃逸分析上差异不大,但当前版本的go对make的大小限制更严格,这么看的话那个八股还是错的,因为make导致逃逸的概率稍大于new。所以该用new就用,不需要在意这些东西。

编译优化太弱鸡拖累逃逸分析

这两年go语言有两个让我对逃逸分析彻底失去兴趣的提交,第一个是:
7015ed

改动就是给一个局部变量加了别名,这样编译器就不会让这个局部变量错误地逃逸了。

为啥编译器会让这个变量逃逸?和编译器实现可达性分析的算法有关,也和编译器没做优化导致分析精度降低有关。

如果你碰到了这种问题,你能想出这种修复手段吗?我反正是不能,因为这个提交这么做是有开发和维护编译器的大佬深入研究之后才定位问题并提出可选方案的,对普通人来说恐怕都想不明白问题出在哪。

另一个是我在1.24开发周期里遇到的。这个提交为了添加新功能对
time.Time
做了点小修改,以前的代码这样:

func (t Time) MarshalText() ([]byte, error) {
        b := make([]byte, 0, len(RFC3339Nano))
        b, err := t.appendStrictRFC3339(b)
        if err != nil {
                return nil, errors.New("Time.MarshalText: " + err.Error())
        }
        return b, nil
}

新的长这样:

func (t Time) appendTo(b []byte, errPrefix string) ([]byte, error) {
	b, err := t.appendStrictRFC3339(b)
	if err != nil {
		return nil, errors.New(errPrefix + err.Error())
	}
	return b, nil
}

func (t Time) MarshalText() ([]byte, error) {
	return t.appendTo(make([]byte, 0, len(RFC3339Nano)), "Time.MarshalText: ")
}

其实就是开发者要复用里面的逻辑,所以抽出来单独做了一个子函数,核心内容都没变。

然而看起来没啥本质区别的新代码,却显示
MarshalText
的性能提升了40%。

怎么回事呢,因为现在
MarshalText
变简单了,所以能在很多地方被内联,而
appendTo
本身不分配内存,这就导致原先作为返回值的buf因为
MarshalText
能内联,编译器发现它在外部调用它的地方并不需要作为返回值而且大小已知,因此适用第二节里我们说到的情况,buf并不需要逃逸。不逃逸意味着不需要分配堆内存,性能自然就提高了。

这当然得赖go过于孱弱的内联优化,它创造出了在c++里几乎不可能出现的优化机会(appendTo就是个包装,还多了一个参数,正常内联展开后和原先的代码几乎不会有啥区别)。这在别的语言里多少有点反常识,所以一开始我以为提交里的描述有问题,花了大把时间排查加测试,才想到是内联可能影响了逃逸分析,一个下午都浪费在这上面了。

这类问题太多太多,issue里就有不少,如果你不了解编译器具体做了什么工作用了什么算法,排查解决这些问题是很困难的。

还记得开头说的么,逃逸分析是要减轻程序员的负担的,现在反过来要程序员深入了解编译器,有点本末倒置了。

这两个提交最终让我开始重新思考开发者需要对逃逸分析了解到多深这个问题。

该怎么做

其实还有很多对逃逸分析的民间传说,我懒得一一证实/证伪了。下面只说在逃逸分析本身就混乱而复杂的情况下,作为开发者该怎么做。

对于大多数开发者:和标题一样,不要过度关注逃逸分析。逃逸分析应该是提升你效率的翅膀而不是写代码时的桎梏。

毕竟光看代码,你很难分析出个所以然来,编译期知道大小可能会逃逸,看起来不知道大小的也可能不会逃逸,看起来相似的代码性能却天差地别,中间还得穿插可达性分析和一些编译优化,corner case多到超乎想象。写代码的时候想着这些东西,效率肯定高不了。

每当自己要想逃逸分析如何如何的时候,可以用下面的步骤帮助自己摆脱对逃逸分析的依赖:

  1. 变量的生命周期是否长于创建它的函数?
  2. 如果是,那么能选用返回“值”代替返回指针吗,函数能被内联或者值的尺寸比较小时复制的开销几乎是可以忽略不计的;
  3. 如果不是或者你发现设计可以修改使得变量的生命周期没有那么长,则往下
  4. 函数是否是性能热点?
  5. 如果不是那么到此为止,否则你需要用memprofile和cpuprofile来确定逃逸带来了多少损失
  6. 性能热点里当然越少逃逸越好,但如果逃逸带来的损失本身不是很大,那么就不值得继续往下了
  7. 复用堆内存往往比避免逃逸更简单也更直观,试试
    sync.Pool
    之类的东西而不是想着避免逃逸
  8. 到了这一步,你不得不用
    -gcflags=-m=2
    看看为什么发生逃逸了,有些原因很明显,可以被优化
  9. 对于那些你看不懂为什么逃逸的,要么就别管了要么用go以外的手段(比如汇编)解决。
  10. 求助他人也是可以的,但前提是他们不是机械式地背背八股文。

总之,遵守一些常见的规定比如在知道slice大小的情况下提前分配内存、设计短小精悍的函数、少用指针等等,你几乎没啥研究逃逸分析的必要。

对于编译器、标准库、某些性能要求较高的程序的开发者来说,了解逃逸分析是必要的。因为go的性能不是很理想,所以得抓住一切能利用的优化机会提升性能。比如我往标准库塞新功能的时候就被要求过一些函数得是“零分配”的。当然我没有上来就研究逃逸,而是先写了测试并研究了profile,之后才用逃逸分析的结果做了更进一步的优化。

总结

这篇文章其实还有一些东西没说,比如数组和闭包在逃逸分析的表现。总体上它们的行为没有和别的变量差太多,在看看文章的标题——所以我不建议过度关注它们的逃逸分析。

所以说,你不应该过度关心逃逸分析。也应该停止背/搬运/编写有关逃逸分析的八股文。

大部分人关心逃逸分析,除了面试之外就是为了性能,我常说的是性能分析一定要结合profile和benchmark,否则凭空臆断为了不逃逸而削足适履,不仅浪费时间对性能问题也没有丝毫帮助。

话说回来,不深入了解逃逸分析和不知道有逃逸分析这东西可是两回事,后者确实约等于go白学了。

Nginx UI是一款专为Nginx设计的图形化管理工具,旨在简化Nginx的配置与管理过程,提高开发者和系统管理员的工作效率。

项目地址:
https://github.com/0xJacky/nginx-ui

一、Nginx UI的主要特点

  • 简化配置:通过图形化的界面,Nginx UI简化了Nginx的配置过程,使得用户无需直接编辑复杂的配置文件即可完成服务器的设置。
  • 实时监控:Nginx UI具备实时监控功能,可以显示Nginx服务器的关键指标,如连接数、请求处理时间等,帮助管理员及时发现并解决问题。
  • 易于扩展:Nginx UI支持插件系统,用户可以根据需求安装额外的功能模块,如日志分析、安全防护等,进一步增强其功能。
  • 高效管理:对于拥有多个Nginx实例的企业级用户来说,Nginx UI提供了一种集中式的管理方式,可以在一个界面上管理所有实例,极大地提高了管理效率。
  • 安全性:Nginx UI在设计时考虑到了安全性问题,提供了多种认证机制,如基于用户的认证、SSL/TLS加密等,确保了数据的安全传输。
  • 兼容性:Nginx UI不仅适用于传统的服务器环境,还支持Docker等容器化平台,使得用户可以在不同的环境中无缝使用Nginx UI进行管理。

二、安装部署

1、环境需求:

  • 操作系统:macOS 11 Big Sur及以上版本(对于ARM架构也有支持)、Linux 2.6.23或更高版本、或者是其它指定的支持平台。
  • Go环境:至少需要Go 1.13+。
  • Node.js:版本需达到21+,配合npx一起使用。

2、安装步骤:

  • 访问Nginx UI的GitHub releases页面:
    https://github.com/0xJacky/nginx-ui/releases
    ,选择适合你系统的最新版下载。

  • 解压缩下载的文件到合适的位置。

  • 复制或创建配置文件app.ini到适当位置,并按需调整。

  • 运行服务:可以通过命令行直接运行nginx-ui -config app.ini,或者利用nohup将其放入后台运行。如果使用systemd管理,则可以通过systemctl命令启动、停止或重启Nginx UI服务。

3、Docker环境下安装:

  • 安装Docker。
  • 从Docker Hub下载官方的Nginx UI镜像。
  • 使用docker run命令启动容器,并映射配置和数据目录到宿主机。
  • 访问指定的URL进行初始设置。

Docker安装部署命令如下:

docker run -dit \
  --name=nginx-ui \
  --restart=always \
  -e TZ=Asia/Shanghai \
  -v /mnt/user/appdata/nginx:/etc/nginx \
  -v /mnt/user/appdata/nginx-ui:/etc/nginx-ui \
  -p 8080:80 -p 8443:443 \
  uozi/nginx-ui:latest

注意:首次使用时,映射到 /etc/nginx 的目录必须为空文件夹。此外,如果您需要托管静态文件,可以直接将文件夹映射到容器中。

三、主要功能介绍

1、仪表盘是 Nginx UI 的核心功能之一,用户可以通过图形化界面监控系统的各项运行指标,包括但不限于 CPU、内存使用情况、系统负载和磁盘使用率。

2、Nginx UI 提供了强大的在线编辑功能。用户可以在浏览器中直接编辑 Nginx 的配置文件,编辑器支持语法高亮,能帮助用户避免配置语法错误。
在这里插入图片描述
3、Nginx 日志查看功能允许用户随时监控和分析 Nginx 的日志,包括访问日志和错误日志。通过该功能,用户可以快速排查网站故障,并深入了解用户访问行为。

4、Nginx UI 提供了直观的站点管理功能。用户可以通过该功能管理多个站点。
在这里插入图片描述

5、Nginx UI 集成了一个基于网页的高级命令行终端。用户可以通过该终端远程访问服务器并执行各种命令,无需单独登录服务器。这对于进行一些高效的命令操作非常有用。
在这里插入图片描述
6、国际化支持:Nginx UI支持多语言设置,目前覆盖英语、简体中文和繁体中文等,满足了不同用户的语言需求。

7、证书管理:支持Let's Encrypt证书的自动化部署,用户可以通过Nginx UI轻松管理SSL证书,确保网站的安全性。

四、小结

总的来说,Nginx UI作为一个高效的Nginx管理工具,不仅提供了强大的功能和灵活的部署选项,还通过其直观的用户界面降低了Nginx管理的复杂性。对于追求高效率和简便操作的开发者和系统管理员而言,Nginx UI是一个值得尝试的优秀工具。无论是新手还是经验丰富的用户,都可以从中受益。

可以通过以下网址访问在线演示系统:
https://demo.nginxui.com/
用户名/密码:
admin/admin

热点随笔:

·
园子商业化的烦恼:吐槽阿里云,得罪了用户
(
博客园团队
)
·
致阿里云:我有一个小需求,请帮忙去掉AI助手
(
博客园团队
)
·
我被 .NET8 JIT 的一个BUG反复折磨了半年之久(JIT tier1 finally optimizations)
(
czd890
)
·
开源的口袋妖怪自走棋「GitHub 热点速览」
(
削微寒
)
·
2024年全面的多端统一开发解决方案推荐!
(
追逐时光者
)
·
程序员开发利器:Your Commands网站上线
(
HarlanC
)
·
C# 并发控制框架:单线程环境下实现每秒百万级调度
(
小码编匠
)
·
盘点.NET支持的 处理器架构
(
张善友
)
·
nicegui太香了,跨平台开发和跨平台运行--使用Python+nicegui实现系统布局界面的开发
(
伍华聪
)
·
坑爹面试官,一个网络连通性,把我干哑火了,无理取闹还是我太菜?
(
JavaBuild
)
·
再见,数据中台,理想还在路上
(
海边的Ivan
)
·
Nginx UI:全新的 Nginx 在线管理平台
(
追逐时光者
)

热点新闻:

·
SpaceX星舰首次完成「筷子夹火箭」,马斯克吹过的牛成了
·
字节跳动大模型训练被实习生攻击,涉事者已被辞退
·
英特尔AMD联盟了,拯救x86
·
多路复用无线传输速度创纪录
·
昔日“神车”,集体大败退
·
Adobe神级AI视频媲美Sora!拖拽一键秒生大片,最强PS震撼设计圈
·
欧几里得望远镜致力绘制最精确宇宙地图
·
马斯克用20分钟,证明中国遥遥领先
·
当按摩店、澡堂、寺庙成为“酒店平替”…
·
用了二十多年电脑,我刚知道windows剪切板也可以保存历史记录,还能跟手机同步
·
安防一哥还是没扛住
·
范德华力堆叠技术造出纠缠光子对

大家好,我是 V 哥。今天咱们来聊一聊 Java 后端确保 JavaScript 不被缓存的问题,先来了解一下为什么需要这样做,通常源于以下几种场景或问题:

1. 先来看几个问题

1.
文件更新后无法及时生效

浏览器缓存机制是为了加快加载速度和减少服务器压力,但有时会带来问题。当 JavaScript 文件更新后,如果浏览器从缓存中加载旧版本,用户就无法看到最新的功能或修复的 Bug。举个例子:开发者发布了新版本的前端代码,修复了一个关键问题,但用户的浏览器仍然使用缓存的旧代码,导致问题依然存在。用户可能以为网站没修复或出现新问题,从而影响用户体验。

2.
前后端不一致

在 Java Web 应用中,JavaScript 通常用于与后端服务交互。如果 JavaScript 代码版本和后端逻辑不一致,可能导致不兼容问题。举个例子:后端接口的请求格式发生变化,但浏览器仍然使用旧的 JavaScript 代码,结果是客户端与服务器之间通信失败,产生错误。

3.
影响调试与开发

在开发和调试环境中,缓存会导致代码变更后无法即时看到效果,这对于调试过程非常不便。开发者可能会在调试中发现修改的代码没有被应用,导致浪费时间。举个例子:开发者修改了 JavaScript 文件,但由于缓存,浏览器继续执行旧的代码,开发者无法验证新代码是否正确,甚至可能以为代码本身有问题。

4.
安全问题

旧的缓存可能会暴露系统之前存在的漏洞。即使后端做了升级,修复了安全漏洞,但如果浏览器加载了旧的 JavaScript 文件,可能仍然会受到攻击。举个例子:假设某个版本的 JavaScript 中存在一个 XSS 漏洞。虽然新版本已经修复了这个漏洞,但浏览器缓存的旧文件仍然暴露在攻击风险中。

所以,如果前端页面无法及时响应更新(如修复 Bug、优化功能等),用户体验可能会受到负面影响。特别是在进行产品版本迭代时,缓存问题可能会使新功能看起来未上线,影响用户的使用体验和满意度。

2. 那要如何解决呢?

在 Java Web 开发中,为了确保 JavaScript 文件(或任何静态资源)不被浏览器缓存,可以使用以下几种经验:

1. 使用版本号或时间戳

为 JavaScript 文件的 URL 添加一个版本号或时间戳,使得每次文件更新后 URL 不同,这样浏览器会认为是新的资源,从而重新加载。比如:

<script src="app.js?v=1.0.1"></script>

或者使用动态的时间戳:

<script src="app.js?t=<%= System.currentTimeMillis() %>"></script>

通过这种方式,每次生成不同的查询参数,浏览器会认为这是一个新的文件,不会从缓存中读取。

2. 设置 HTTP 响应头

在 Java 后端(比如 Spring Boot 或 Servlet),可以通过设置 HTTP 头来控制缓存。常见的 HTTP 头包括:

  • Cache-Control
    : 控制缓存行为。
  • Pragma
    : 控制缓存行为(主要用于兼容 HTTP/1.0)。
  • Expires
    : 设置资源过期的时间。

示例代码(Spring Boot 过滤器):

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class NoCacheFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
        throws IOException, ServletException {
        
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
        httpResponse.setHeader("Pragma", "no-cache");
        httpResponse.setHeader("Expires", "0");
        
        chain.doFilter(request, response);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void destroy() {}
}

3. 配置静态资源的缓存策略

在 Spring Boot 项目中,可以通过配置类来定义静态资源的缓存策略。例如:

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/js/**")
                .addResourceLocations("classpath:/static/js/")
                .setCachePeriod(0); // 0 表示不缓存
    }
}

通过
setCachePeriod(0)
设置缓存周期为 0,强制浏览器每次都从服务器获取最新的 JavaScript 文件。

4. 使用
ETag

Last-Modified

在 HTTP 响应中设置
ETag

Last-Modified
,这样浏览器在每次请求时都会询问服务器资源是否更新。如果没有变化,服务器会返回 304 状态码,从而避免不必要的重新加载。

示例(设置
Last-Modified
):

httpResponse.setDateHeader("Last-Modified", System.currentTimeMillis());

上面的几种方法,能确保浏览器及时获取最新版本的 JavaScript 文件,不使用缓存的旧版本。

关于一些思考

问题来了,那什么时候可以使用缓存?

虽然缓存可能带来这些问题,但并不意味着缓存永远不好。在某些场景中,使用浏览器缓存可以显著提升性能:

  • 静态资源(如 JavaScript、CSS 文件)较少更改时,缓存可以显著减少网络请求,提升页面加载速度。
  • 确保在更新时有效控制缓存机制(比如用文件的版本号或哈希值作为文件名的一部分)可以避免不必要的重新下载和过度加载。

如何平衡?

通常,咱们不会完全禁止缓存,而是通过版本号、哈希、缓存控制头等方式来平衡性能和更新问题。这样,浏览器在没有必要时可以利用缓存,而在需要时也能获取最新的资源。
关注威哥爱编程,码码通畅不掉发

库安装

首先,安装 Mock 类生成工具 Mockery:

go install github.com/vektra/mockery/v2@v2.45.1

实际上,你也可以手动创建 Mock 类。

生成 Mock 类

假设你在
internal/metrics
包下有如下定义的接口:

package metrics

type Getter[T any] interface {
    Get() (T, error)
}

在项目根目录,可以使用以下命令生成 Mock 类:

mockery --name=Getter --dir=internal/metrics

生成的 Mock 类会在
mocks
目录下的
getter.go
文件中。

编写用例

package metrics

import (
	"testing"

	mocks "xxx/mock/internal_/metrics"
	"github.com/stretchr/testify/suite"
)

type GetterTestSuite struct {
	suite.Suite
}

func TestGetter(t *testing.T) {
	suite.Run(t, new(GetterTestSuite))
}

func (t *GetterTestSuite) TestGetterInt() {
	t.T().Logf("TestGetterInt run")
	getter := new(mocks.Getter[int])
	getter.On("Get").Return(1, nil)

	val, err := getter.Get()
	t.Nil(err)
	t.Equal(1, val)
}

说明

  1. GetterTestSuite
    是测试集的名称,
    每个method
    都会作为测试用例调用。
    TestGetter
    函数运行时,会调用
    TestGetterInt
  2. TestGetterInt
    中引用的
    t

    TestSuite
    ,包含许多有用的断言函数,如
    Equal

    Nil
    等。
  3. 创建 Mock 实例后,可以使用
    On
    方法来标记方法对应的返回值。假设
    Get
    方法可以传递参数,则可以根据不同的参数选择不同的返回值。

Mock 常见用法

假设
mockObj
是 Mock 类的实例:

  1. mockObj.On("GetApiKey", mock.Anything).Return("dummy_api_key")

    GetApiKey
    有一个参数,且无论传什么,都会返回
    dummy_api_key
  2. mockObj.On("GetAllClusterInfo").Maybe().Return(GenerateTestClustersInfo())
    :如果使用
    Maybe
    ,则
    GetAllClusterInfo
    不一定必须被调用;如未使用
    Maybe
    且函数未被调用,则断言将失败。
  3. mockObj.On("RunCleanup", true, true).Once().Return(nil, nil)

    RunCleanup
    有两个参数,所以需要传递两个 Mock 的值进入。
    Once
    表示这个函数只应该被调用一次。
  4. mockObj.AssertNumberOfCalls(t.T(), "RunCleanup", 4)
    :可以检查方法的调用次数。

通过这些用法,用户可以完全控制 Mock 类的每个方法的行为,并进行一些检查以完善整个测试。