2024年3月

1、Playwright介绍

Playwright
是一个由
Microsoft
开发的开源自动化测试工具,它可以用于测试Web应用程序。Playwright支持多种浏览器,包括Chrome、Firefox和WebKit,同时也支持多种编程语言,如JavaScript、TypeScript、Python和C#。

2、特点、使用场景

Playwright具有以下特点:

  • 支持所有主流浏览器
  • 跨平台:Windows、Linux 和macOS
  • 可用于模拟移动端WEB应用的测试。
  • 支持无头模式(默认)和有头模式

使用场景包括但不限于:

  • 自动化测试:开发人员和测试人员可以使用Playwright来编写自动化测试脚本,验证Web应用程序的功能和性能。
  • 性能测试:Playwright可以模拟多个用户同时访问Web应用程序,帮助测试应用程序在高负载情况下的性能表现。
  • 可访问性测试:Playwright可以模拟用户使用辅助技术(如屏幕阅读器)访问Web应用程序,帮助测试应用程序的可访问性。
  • 网页截图和录制:Playwright可以生成网页截图,也可以录制用户操作过程,帮助开发人员和测试人员分析问题。

总之,Playwright可以用于各种Web应用程序测试场景,帮助团队提高测试效率和测试覆盖率。

3、架构原理

playwright共由三部分组成:

  • client:在客户端是我们用不同的编程语言编写的代码,如JavaScript,Java,Python,C#等。

  • server:Playwright的server通过nodejs构建并负责与client 以及不同的 Web 浏览器引擎进行通信。

  • 通信协议:client通过WebSocket 协议与Playwright server 通信;

Playwright使用 Chrome DevTools 协议(CDP)与 Chromium 通信。触发测试后,client端代码将被转换为JSON格式,然后使用websocket协议发送到服务器。palywright通过单个 websocket 协议连接传达所有请求,该连接将保持不变,直到所有测试执行完成。由于命令是在单个连接上发送的,因此测试失败或不稳定的可能性较小,并且命令可以快速执行。这种架构与Selenium相反,Selenium使用HTTP连接协议,并将每个命令(如浏览器打开,单击,关闭浏览器)作为单独的HTTP请求发送。此外,在Selenium中,服务器和客户端之间的连接将在每次请求后终止,并为下一个请求重新建立。
这也是Playwright为什么比selenium快的原因!

4、环境安装

Playwright环境部署简单,不像Selenium那样需要下载浏览器对应版本的驱动程序chromedriver,直接安装即可:

# 1、安装 playwright:
  pip3 install playwright

#  2、安装所需的浏览器 chromium,firefox 和 webkit:
 playwright install
 
# 3、如何只安装特定的浏览器,比如Chrome: 
playwright install chromium

如果您在使用
pip3 install playwright
安装 Playwright 时遇到下载速度缓慢的问题,可以尝试以下方法来加速下载:

更换 pip 镜像源

可以使用国内的 pip 镜像源来替代默认的源,例如使用阿里云的 pip 镜像源或者清华大学的镜像源等。可以使用以下命令来更换 pip 镜像源:

pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/

5、快速开始

Playwright 支持2种运行方式:
同步

异步

# 异步代码
from time import sleep

from playwright.async_api import async_playwright
async def main():
    async with async_playwright() as driver:
        browser=await driver.firefox.launch(headless=False)
        page=await browser.new_page()
        await page.goto('https://www.example.com')
        print(await page.title())
        await browser.close()
        sleep(10)

asyncio.run(main())

# 同步执行代码
from playwright.sync_api import sync_playwright
with sync_playwright() as driver:
    browser=driver.chromium.launch(headless=False)
    page=browser.new_page()
    page.goto('https://www.example.com')
    print(page.title())
    sleep(10)
    browser.close()

6、代码自动生成

Playwright自带了一个代码生成器工具,可以帮助你快速生成Playwright自动化测试脚本。通过代码生成器,你可以在浏览器中进行交互式操作,然后自动生成相应的Playwright代码。

使用Playwright代码生成器的基本步骤:

playwright codegen https://playwright.dev
  • 在网页中,你可以选择要使用的编程语言(如JavaScript、Python、TypeScript),以及要测试的目标网站。

  • 点击“Start recording”按钮,然后在弹出的浏览器窗口中进行交互式操作,如点击按钮、填写表单等。

  • 完成交互式操作后,点击“Stop recording”按钮。

  • 在页面中将生成的代码显示出来,你可以复制并粘贴到你的项目中使用。

通过使用代码生成器,你可以节省大量时间,特别是对于那些不熟悉Playwright API的开发人员来说,这是一个非常有用的工具。生成的代码可以作为起点,然后根据需要进行修改和扩展。

7、追踪查看器

在Playwright中,追踪查看器(Trace Viewer)是一个非常有用的工具,可以帮助你分析和调试自动化测试过程中的性能问题和其他关键信息。通过追踪查看器,你可以查看详细的性能数据、网络请求、页面事件等,以便更好地了解自动化测试的执行过程。

以下是使用追踪查看器的基本步骤:

1、启用追踪:

在Playwright的代码中,你可以通过调用start_tracing方法来启用追踪功能。例如:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context()
    page = context.new_page()
    trace_path = 'trace.json'
    page.start_tracing(path=trace_path)
    page.goto('https://www.example.com')
    page.click('button#submit')
    page.wait_for_navigation()
    page.stop_tracing()
    context.close()
    browser.close()

2、保存追踪数据:
在代码中指定追踪数据保存的路径,如上例中的trace_path = 'trace.json'。

3、打开追踪查看器:
在浏览器中,打开Playwright追踪查看器网站:
https://playwright.dev/docs/trace-viewer/

4、加载追踪数据:
在追踪查看器网站中,点击“Load”按钮,然后选择你保存的追踪数据文件(如trace.json)。

5、分析追踪数据:
一旦加载了追踪数据,你就可以在追踪查看器中查看各种性能指标、网络请求、页面事件等信息。你可以使用追踪查看器的各种功能和过滤器来深入分析自动化测试的执行过程,找出潜在的性能问题和优化空间。

官网:

https://playwright.dev/

更多特性可参考项目地址:
https://github.com/microsoft/playwright

简介

Sauron
是一个多功能的 Web 框架和库,用于构建客户端和/或服务器端 Web 应用程序,
重点关注人体工程学、简单性和优雅性
。这使您可以编写尽可能少的代码,并更多地关注业务逻辑而不是框架的内部细节。
github:
https://github.com/ivanceras/sauron
文档:
https://sauron-rs.github.io/

架构

Sauron 遵循
模型-视图-更新架构
(也称为
Elm 架构
),它总是分为三个部分:

  • 模型
    - 应用程序的状态
  • 视图
    - 一种将状态转换为 HTML 的方法
  • 更新
    - 一种根据消息更新状态的方法

Application 和组件

为了使模型在 Sauron 程序中运行,它必须实现
Application trait
,然后定义下面两个函数:

  • view 函数
    :该函数告诉程序如何显示模型。
  • update 函数
    :该函数描述如何根据消息更新模型状态。

简单入门示例

先决条件

确保已安装所有必备组件:

  • rust and cargo
    :Rust基础环境和工具。
  • wasm-pack
    :将 rust 代码编译到 webassembly 中,然后放入 ./pkg 目录中。
  • basic-http-server
    :在本地提供静态文件。

执行以下命令安装
wasm-pack

cargo install wasm-pack

执行以下命令安装
basic-http-server

cargo install basic-http-server

wasm-pack 默认会使用
wasm-opt
工具进行大小优化,而这个工具也是运行时下载安装的。下载 wasm-opt 使用的是 github 链接,国内环境大概率是下载失败的,可以参考
如何安装WASM-OPT?
手动下载 wasm-opt.exe 后放到
.cargo\bin
路径下。

创建项目

创建一个名为 hello 的新项目:

cargo new --lib hello

在 Cargo.toml 中指定这个 crate 需要编译为
cdylib

动态系统库
):

[lib]
crate-type = ["cdylib"]

执行以下命令,添加
sauron
作为项目的依赖项。

cargo add sauron

编译库文件

修改 src/lib.rs代码,在段落中显示“hello”文本:

use sauron::{node, wasm_bindgen, Application, Cmd, Node, Program};

struct App;

impl Application<()> for App {
    fn view(&self) -> Node<()> {
        node! {
            <p>
                "hello"
            </p>
        }
    }

    fn update(&mut self, _msg: ()) -> Cmd<Self, ()> {
        Cmd::none()
    }
}

//函数应该在加载 wasm 模块时自动启动,类似于 main 函数
#[wasm_bindgen(start)]
pub fn main() {
    Program::mount_to_body(App::new());
}
  • view 方法中使用 node! 宏,采用类似 html 的语法来显示应用程序。
  • 为 App 实现 Application 特征,实现必要的方法来告诉 sauron 应用程序的行为。
  • 这里的 main 函数在加载 wasm 模块时自动启动,函数可以任意命名,只要配置 start 即可。

执行以下命令进行编译:

wasm-pack build --release --target=web

编译时间稍微有点长,wasm-pack 会在项目中创建一个文件夹
./pkg
,里面包含生成的编译文件,只需要关注其中 2 个文件:

hello.js
hello_bg.wasm

它们的名称派生自给定的包名称
<package_name>.js

<package_name>_bg.wasm

引用库文件

在项目的根目录中创建 index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <script type=module>
      import init from './pkg/hello.js';
      await init().catch(console.error);
    </script>
  </body>
</html>
  • 使用的是 <script type=module>,
    从 ./pkg 文件夹中引用了 ./pkg/hello.js
  • 在后台,
    ./pkg/hello.js 将负责在后台加载 ./pkg/hello_bg.wasm

运行项目

重新编译webapp,每次对 rust 代码进行更改时发出此命令。

wasm-pack build --release --target=web

最后使用 basic-http-server 提供文件:

basic-http-server

默认情况下,它在端口 4000 中提供页面,导航到
http://127.0.0.1:4000
以查看“hello”消息。

界面交互示例

在浏览器中显示 3 个按钮,单击这些按钮可以增加/减少和重置计数。

创建项目

创建一个名为 counter 的新 rust 库项目:

cargo new --lib counter

接下来修改 crate 类型为 “cdylib” 库 ,添加 sauron 依赖,这里不在赘述。

编译库文件

在 src/lib.rs 中放入此代码:

use sauron::prelude::*;
use sauron::node;

struct App {
    count: i32,
}

//添加了一个函数 new 来创建以 count 0 开头的初始状态 App
impl App {
    fn new() -> Self {
        App { count: 0 }
    }
}

定义应用程序将具有的一组操作:

enum Msg {
    Increment,
    Decrement,
    Reset,
}

为模型 App 实现 Application 特征:

impl Application<Msg> for App {
    fn view(&self) -> Node<Msg> {
        node! {
            <main>
                <input type="button"
                    value="+"
                    on_click=|_| {
                        Msg::Increment
                    }
                />
                <button class="count" on_click=|_|{Msg::Reset} >{text(self.count)}</button>
                <input type="button"
                    value="-"
                    on_click=|_| {
                        Msg::Decrement
                    }
                />
            </main>
        }
    }

    fn update(&mut self, msg: Msg) -> Cmd<Self, Msg> {
        match msg {
            Msg::Increment => self.count += 1,
            Msg::Decrement => self.count -= 1,
            Msg::Reset => self.count = 0,
        }
        Cmd::none()
    }
}
  • view 方法返回类型为 Node<Msg>,这意味着它创建一个 html 节点,其中
    它的任何成员 html 元素都有一个事件侦听器,该事件侦听器可以向程序处理程序发出 Msg 消息
  • update 方法接受 Msg 作为参数,并根据 Msg 的变体修改模型 App。

实现应用函数

接下来为 wasm Web 应用定义入口点,通过使用 #[wasm_bindgen(start)] 注释公共函数来完成:

#[wasm_bindgen(start)]
pub fn start() {
    Program::mount_to_body(App::new());
}

为了演示纯函数交互,这里再添加一个简单的加法函数:

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
  a + b
}

如果只需要js调用Rust的函数,那只需要添加 wasm_bindgen 依赖即可,参考
使用Rust和WebAssembly整花活儿(一)——快速开始

引用库文件

在项目基本文件夹的 index.html 文件中链接应用,可以像往常一样放置样式:

<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
    <title>Counter</title>
    <style type="text/css">
        body { font-family: verdana, arial, monospace; }
        main {
            width:30px;
            height: 100px;
            margin:auto;
            text-align: center;
        }
        input, .count{
            font-size: 40px;
            padding: 30px;
            margin: 30px;
        }
    </style>
    <script type=module>
        import init, { add } from './pkg/counter.js';
        await init().catch(console.error);
        const result = add(1, 2);
        console.log(`the result from rust is: ${result}`);
    </script>
  </head>
  <body>
  </body>
</html>

注意上面的
import init, { add } from
,add 函数在使用前需要导入,否则会调用失败。

运行项目

编译库文件:

wasm-pack build --release --target=web

启动静态站点:

basic-http-server

访问
http://127.0.0.1:4000
查看效果:
image

参考资料

前言:

在动态生成代码的过程中,构建类型(Type)是至关重要的一步。

通过使用 Emit 中的 TypeBuilder,我们可以定义和创建各种类型,包括类、结构体和接口。

本节将深入探讨如何使用 TypeBuilder 动态构建类型,并介绍其在实际应用中的重要性。

定义公用代码,生成程序集以供对照:

通过学习本系列之前的文章,我们可以轻松定义 AssemblyBuilder 程序集构建器,再通过程序集构建器,定义 ModuleBuilder 模块构建器。

下面我们先通过定义公用代码来生成程序集,以便更好的通过反编绎,来观察对照我们生成的代码。

AssemblyName assName = new AssemblyName("myAssembly");
AssemblyBuilder ab
=AppDomain.CurrentDomain.DefineDynamicAssembly(assName, AssemblyBuilderAccess.RunAndSave);
ModuleBuilder mb
= ab.DefineDynamicModule("myModule","a.dll");//...今天的示例代码存放地 ab.Save("a.dll");

注意标红的部分为 .NET 版本代码,正如本系列之前文件所说,只有 .NET 版本支持程序集持久化,.NET Core 需要到9版本才支持。

.NET Core 用 AssemblyBuilder.DefineDynamicAssembly来构建。

ModuleBuilder 的几个定义方法:

1、定义枚举:

EnumBuilder eb=mb.DefineEnum("bbb", ...);

2、定义类(包括类、接口、结构体):

TypeBuilder tb=mbDefineType("aaa", ...);

3、定义内部类:

TypeBuilder tb=mbDefineType("aaa", ...);
TypeBuilder innerClassBuilder
= tb.DefineNestedType("innerClass",...);

下面我们使用代码对照,来学习本节内容:

1、定义枚举:

EnumBuilder eb = mb.DefineEnum("MyNameSpace.MyEnum", TypeAttributes.Public, typeof(int));

eb.DefineLiteral(
"Spring", 0);
eb.DefineLiteral(
"Summer", 1);
eb.DefineLiteral(
"Autumn", 2);
eb.DefineLiteral(
"Winter", 3);

Type enumType
= eb.CreateType();

对应生成的代码:

2、定义接口:

 TypeBuilder tb = mb.DefineType("MyNameSpace.MyInterface", TypeAttributes.Public | TypeAttributes.Abstract |TypeAttributes.Interface);//tb.DefineField("ID", typeof(int), FieldAttributes.Public| FieldAttributes.Static| FieldAttributes.InitOnly);//定义属性 "Name",类型为 int
 PropertyBuilder propertyBuilder = tb.DefineProperty("Name", PropertyAttributes.None, typeof(int), null);//定义属性的 getter 方法
 MethodBuilder getterMethodBuilder = tb.DefineMethod("get_Name", MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual, typeof(int), Type.EmptyTypes);
propertyBuilder.SetGetMethod(getterMethodBuilder);//定义属性的 setter 方法
 MethodBuilder setterMethodBuilder = tb.DefineMethod("set_Name", MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual, null, new Type[] { typeof(int) });
propertyBuilder.SetSetMethod(setterMethodBuilder);
//定义方法 GetMyName
MethodBuilder getMyName
= tb.DefineMethod("GetMyName", MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual, typeof(string), new Type[] { typeof(int) });

tb.CreateType();

属性的定义,需要挂接 get_XXX 和 set_XXX 两个方法,会相对显的定义麻烦了点。

对应生成的代码:

3、定义结构体

//定义结构体
TypeBuilder tb = mb.DefineType("MyNameSpace.MyStruct", TypeAttributes.SequentialLayout | TypeAttributes.Public | TypeAttributes.Sealed, typeof(ValueType));//定义字段
tb.DefineField("ID", typeof(int), FieldAttributes.Public);tb.DefineField("Name", typeof(string), FieldAttributes.Public);

tb.CreateType();

对应生成的代码:

4、定义类:抽象类

 TypeBuilder tb = mb.DefineType("MyNameSpace.MyClassBase", TypeAttributes.Public | TypeAttributes.Abstract |TypeAttributes.Class);
tb.DefineField(
"ID", typeof(int), FieldAttributes.Public);
tb.DefineMethod(
"MyProtectedMethod", MethodAttributes.Family| MethodAttributes.Abstract | MethodAttributes.Virtual, typeof(void), Type.EmptyTypes);
tb.CreateType();


tb.CreateType();

MethodAttributes.Family 对应的即:protected 修饰符。

对应生成的代码:

5、定义类:并继承自抽象类:

//定义基类
TypeBuilder tb = mb.DefineType("MyNameSpace.MyClassBase", TypeAttributes.Public | TypeAttributes.Abstract |TypeAttributes.Class);
tb.DefineField(
"ID", typeof(int), FieldAttributes.Public);
tb.DefineMethod(
"MyProtectedMethod", MethodAttributes.Family | MethodAttributes.Abstract | MethodAttributes.Virtual, typeof(void), Type.EmptyTypes);

Type typeBase
=tb.CreateType();
//定义子类,继承基类
TypeBuilder tbClass
= mb.DefineType("MyNameSpace.MyClass", TypeAttributes.Public |TypeAttributes.Class, typeBase);//实现抽象方法 MethodBuilder mbClass = tbClass.DefineMethod("MyProtectedMethod", MethodAttributes.Family | MethodAttributes.Virtual, typeof(void), Type.EmptyTypes);
ILGenerator iL
=mbClass.GetILGenerator();
iL.Emit(OpCodes.Ret);
tbClass.CreateType();

红色标注为指定继承,接口继承一样在该参数指定。

对应生成的代码:

6、定义类:增加泛型参数指定

//定义基类
TypeBuilder tb = mb.DefineType("MyNameSpace.MyClassBase", TypeAttributes.Public | TypeAttributes.Abstract |TypeAttributes.Class);
tb.DefineField(
"ID", typeof(int), FieldAttributes.Public);
tb.DefineMethod(
"MyProtectedMethod", MethodAttributes.Family | MethodAttributes.Abstract | MethodAttributes.Virtual, typeof(void), Type.EmptyTypes);

Type typeBase
=tb.CreateType();//定义子类继承基类 TypeBuilder tbClass = mb.DefineType("MyNameSpace.MyClass", TypeAttributes.Public |TypeAttributes.Class, typeBase);//实现抽象方法 MethodBuilder mbClass = tbClass.DefineMethod("MyProtectedMethod", MethodAttributes.Family | MethodAttributes.Virtual, typeof(void), Type.EmptyTypes);
ILGenerator iL
=mbClass.GetILGenerator();
iL.Emit(OpCodes.Ret);
//定义泛型参数 string[] typeParamNames = { "T"};
GenericTypeParameterBuilder[] typeParams
=tbClass.DefineGenericParameters(typeParamNames);//定义泛型方法 MethodBuilder methodBuilder = tbClass.DefineMethod("GetT", MethodAttributes.Public, typeParams[0], new Type[] { typeof(object) });
ILGenerator ilGenerator
=methodBuilder.GetILGenerator();
ilGenerator.Emit(OpCodes.Ldarg_0);
ilGenerator.Emit(OpCodes.Ret);


tbClass.CreateType();

这里通过定义泛型参数,来指定我们的泛型类。

对应生成的代码:

7、通过内部类定义委托:

//定义内部类,并在内部类中定义委托类型
TypeBuilder delegateBuilder = tbClass.DefineNestedType("MyNameSpace.AuthDelegate", TypeAttributes.Class | TypeAttributes.NestedPublic | TypeAttributes.Sealed, typeof(MulticastDelegate));//添加委托的构造函数
ConstructorBuilder constructor = delegateBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, new Type[] { typeof(object), typeof(IntPtr) });
constructor.SetImplementationFlags(MethodImplAttributes.Runtime
|MethodImplAttributes.Managed);//添加Invoke方法 delegateBuilder.DefineMethod("Invoke", MethodAttributes.Public, typeof(bool), new Type[] { typeof(string) }).SetImplementationFlags(MethodImplAttributes.Runtime |MethodImplAttributes.Managed);//创建内部类和委托类型 Type authDelegateType = delegateBuilder.CreateType();

注意,这里是通过Type的形式,来定义委托。

因此,我们对其限定名称空间,限定其使用范围:

同时将委托定义在某个类当成员变量:

通过定义事件,是使用委托的方式之一。

8、定义事件:

//定义事件
EventBuilder eb = tbClass.DefineEvent("MyEvent", EventAttributes.None, delegateBuilder);

MethodBuilder addMethod
= tbClass.DefineMethod("add_OnAuth", MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName, typeof(void), newType[] { delegateBuilder });
ILGenerator addMethodIL
=addMethod.GetILGenerator();//...... addMethodIL.Emit(OpCodes.Ret);
eb.SetAddOnMethod(addMethod);

MethodBuilder removeMethod
= tbClass.DefineMethod("remove_OnAuth", MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName, typeof(void), newType[] { delegateBuilder });
ILGenerator removeMethodIL
=removeMethod.GetILGenerator();//...... removeMethodIL.Emit(OpCodes.Ret);
eb.SetRemoveOnMethod(removeMethod);

注意事项:

1、定义事件,通过特殊方法: DefineEvent 来定义。

2、定义事件,第三个事件参数Type,需要传递 delegateBuilder ,则不是 delegateType,否则会报错:

3、定义事件,需要同时挂两个对应的添加和移除方法,否则,运行正常,但反编绎会报错:

4、定义方法,传递的委托类型,和注意事项2一致,需要传递 delegateBuilder,否则一样的错误信息。

下面查看正常情况下的反绎绎生成代码:

对委托和事件的定义,一个神奇的Bug:

通过反编绎 ILSpy 软件,可以看到已经定义成功了,但通过引用生成的程序集,即发现里面没有相关的委托或事件产生?

同时通过 VS2022 自带的反编绎【直接F12跳转】,里面也没有任何相关的委托或事件代码?

总结

构建类型是动态代码生成过程中的关键一环,通过灵活运用 TypeBuilder 和相关工具,

我们可以实现各种复杂类型的动态生成,为程序的灵活性和可扩展性提供有力支持。

总的来说,本章节通过演示如何使用 Emit 来动态创建类型,包括定义字段、方法、属性和事件等,

帮助读者理解如何在运行时生成和操作类型信息。

基础介绍

官方地址:
https://docs.pytest.org/en/8.0.x/reference/reference.html#config-cache


  • pytest
    中,
    cache
    是一个非常有用的功能,它允许测试会话之间持久化状态
  • 这意味着可以在一次测试运行中存储一些值,并在后续的测试运行中访问这些值

如何使用
cache

cache
对象通过
pytest

FixtureRequest
对象提供,通常在一个 fixture 中获取并返回它

@fixture
def cache(request: FixtureRequest) -> Cache:
    """Return a cache object that can persist state between testing sessions.

    cache.get(key, default)
    cache.set(key, value)

    Keys must be ``/`` separated strings, where the first part is usually the
    name of your plugin or application to avoid clashes with other cache users.

    Values can be any object handled by the json stdlib module.
    """
    assert request.config.cache is not None
    return request.config.cache

image.png

  • pytest 的缓存机制是通过一个名为 .pytest_cache 的目录实现的,该目录位于项目的根目录下
  • config.cache 对象提供了一个简单的键值存储接口,允许测试代码读取和写入缓存数据
  • 一个 key 一个文件

存储和检索数据

cache
对象提供了两个主要方法:
get

set

  • get(key, default=None)
    方法用于检索之前存储的值。如果指定的
    key
    不存在,则返回
    default
    值。
  • set(key, value)
    方法用于存储值。
    key
    应该是一个字符串,而
    value
    可以是任何可以被
    json
    标准库模块处理的对象。
# 设置缓存值
config.cache.set("key", "value")

# 获取缓存值
value = config.cache.get("key", None)  # 如果键不存在,返回 None

键建议使用
/
分隔的字符串,其中第一部分通常是你的插件或应用程序的名称,以避免与其他使用
cache
的代码冲突

示例

假设有一个测试,需要从外部API获取数据,但这个操作很耗时,可以在第一次运行测试时从API获取数据,并将其存储在
cache
中。在后续的测试运行中,可以直接从
cache
中检索数据,避免重复的API调用

def test_external_api(cache):
    # 尝试从缓存中获取数据
    data = cache.get('external_api/data', default=None)
    if data is None:
        # 如果缓存中没有数据,则从API获取并存储到缓存中
        data = fetch_data_from_external_api()  # 假设这是一个函数来获取数据
        cache.set('external_api/data', data)
    
    # 使用数据进行测试
    assert data is not None

源码解析

pytest 的缓存机制是在 _pytest/cacheprovider.py 文件中实现的

def get(self, key: str, default):
    """Return the cached value for the given key.

    If no value was yet cached or the value cannot be read, the specified
    default is returned.

    :param key:
        Must be a ``/`` separated value. Usually the first
        name is the name of your plugin or your application.
    :param default:
        The value to return in case of a cache-miss or invalid cache value.
    """
    path = self._getvaluepath(key)
    try:
        with path.open("r") as f:
            return json.load(f)
    except (ValueError, OSError):
        return default

def set(self, key: str, value: object) -> None:
    """Save value for the given key.

    :param key:
        Must be a ``/`` separated value. Usually the first
        name is the name of your plugin or your application.
    :param value:
        Must be of any combination of basic python types,
        including nested types like lists of dictionaries.
    """
    path = self._getvaluepath(key)
    try:
        if path.parent.is_dir():
            cache_dir_exists_already = True
        else:
            cache_dir_exists_already = self._cachedir.exists()
            path.parent.mkdir(exist_ok=True, parents=True)
    except OSError:
        self.warn("could not create cache path {path}", path=path, _ispytest=True)
        return
    if not cache_dir_exists_already:
        self._ensure_supporting_files()
    data = json.dumps(value, indent=2)
    try:
        f = path.open("w")
    except OSError:
        self.warn("cache could not write path {path}", path=path, _ispytest=True)
    else:
        with f:
            f.write(data)
  • 代码还是比较简单的,key 就是一个文件路径,不存在则创建,然后写入数据
  • 这些方法最终会将数据序列化为 JSON 格式

今天讲一个常见的gc compiler(也就是官方版本的go编译器和runtime)在垃圾回收的扫描标记阶段做的优化。

我对这个优化的描述印象最深的是在bigcache的注释里,大致内容是如果map的键值都不包含指针,那么gc扫描的时候不管这个map多大都不会深入扫描map内部存储的数据,只检查map本身是否需要回收。

这么做的好处显然是可以让gc的扫描速度大大增加,从而减少gc对性能的损耗。

减少指针数量本身就是常见的优化手段,但让我感到好奇的是注释里说的“跳过”。跳过的依据究竟是什么,以及只有map存在这种跳过吗?

于是我进行了全面的搜索,结果除了复读bigcache里那段话的,没什么有用的发现。

于是这篇文章诞生了。

跳过扫描指的是什么

前置知识少不得。

简单的说,gc在检查对象是否存活的时候,除了对象本身,还要检查对象的子对象是否引用了其他对象,具体来说:

  • 数组和slice的话指存储在里面的每一个元素是否存活,这里被存储的元素是数组/slice的子对象
  • map的子对象就是里面存的键和值了
  • struct的子对象是它的每一个字段

为了检查这些子对象是否引用了其他对象(关系到这些被引用的对象是否能被回收),gc需要深入扫描这些子对象。子对象越多需要扫描的东西就越多。而且这个过程是递归的,因为子对象也会有子对象,想象一下嵌套的数组或者map。

跳过扫描自然就是指跳过这些子对象的扫描,只需要检查对象本身即可的操作。

什么样的对象是可以跳过扫描的

这也是我的第一个疑问。跳过或不跳过的依据是什么,又或者是什么东西在控制这一过程。

bigcache告诉我们存有不包含指针的键值对的map是可以跳过的,那么具体情况是怎么样的呢?

找不到有用的资料,那只能看代码了,代码以Go 1.22.1为准。

首先应该想到的应该是从gc的代码开始看,于是很快就有了收获:

// runtime/mgcmark.go
// 负责gc扫描的函数,还有个它的兄弟gcDrainN,代码差不多就不放了
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    ...
    // 先标记所有root对象,检查对象是否存活就是从这开始的
    if work.markrootNext < work.markrootJobs {
		for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
			markroot(gcw, job, flushBgCredit)
			// 检查自己是否需要被中断,需要的场合函数会直接跳到收尾工作然后返回
		}
	}

    // 从工作队列里拿需要扫描的对象进行处理
    for !(gp.preempt && (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
		b := gcw.tryGetFast() // 从工作队列拿对象
		scanobject(b, gcw)
        ...
	}
    ...
}

流程不考虑中断、数据统计和校验的话还是很简单的,就是先标记扫描的起点,然后从gcw这个工作队列里拿东西出来处理,直到工作队列里再也没数据了为止。

markroot
也很简单,根据root对象的种类,它会调用
scanblock
或者
markrootSpans
。其中
scanblock
会调用
greyobject
来标记待处理的对象。因此稍微看看
markrootSpans
即可。

markrootSpans
是用来处理那些存放设置了终结器的对象的内存的:

// runtime/mgcmark.go
func markrootSpans(gcw *gcWork, shard int) {
	...
	for i := range specialsbits {
		...
		for j := uint(0); j < 8; j++ {
			// 找到要处理的span(go内存使用的单位,你就当是“一块内存空间”就行)
			s := ha.spans[arenaPage+uint(i)*8+j]

			...
            lock(&s.speciallock)
			for sp := s.specials; sp != nil; sp = sp.next {
				if sp.kind != _KindSpecialFinalizer {
					continue
				}
				// don't mark finalized object, but scan it so we
				// retain everything it points to.
                // spf是终结器本身
				spf := (*specialfinalizer)(unsafe.Pointer(sp))
				// A finalizer can be set for an inner byte of an object, find object beginning.
				p := s.base() + uintptr(spf.special.offset)/s.elemsize*s.elemsize

				// p是设置了终结器的对象
                // 这里检查这个对象占用的内存上是否设置了跳过扫描的标记
                // 设置了的话就不要继续扫描对象自己的子对象了
				if !s.spanclass.noscan() {
					scanobject(p, gcw)
				}

				// 这个span本身就是root对象,所以剩下的直接用scanblock处理
				scanblock(uintptr(unsafe.Pointer(&spf.fn)), goarch.PtrSize, &oneptrmask[0], gcw, nil)
			}
			unlock(&s.speciallock)
		}
	}
}

其实很简单,依旧是找到所有的对象,然后进行处理。然而我们看到了有意思的东西:
s.spanclass.noscan()

看起来这和是否跳过扫描有关。

但我们先不深入这个方法,为什么?因为终结器是被特殊处理的,没看完
scanobject

greyobject
之前我们不能断言这个方法是否控制着对对象的扫描。(其实注释上我已经告诉你就是这个东西控制的了,但如果你自己跟踪代码的话头一次看到这段代码的时候是不知道的)

所以我们接着看
scanobject
,这个函数是扫描对象的子对象的:

// runtime/mgcmark.go
func scanobject(b uintptr, gcw *gcWork) {
	// 先拿到还没扫描过的内存
	s := spanOfUnchecked(b)
	n := s.elemsize
    // n 表示mspan里有几个对象,在被这个函数检查的时候肯定不能是0
	if n == 0 {
		throw("scanobject n == 0")
	}
	if s.spanclass.noscan() {
		// 如果内存设置了noscan标志,就报错
		throw("scanobject of a noscan object")
	}

	var tp typePointers
	if n > maxObletBytes {
		// 大内存分割成不同的块放进工作队列,这样能被并行处理
		if b == s.base() {
			// 分割后入队
			for oblet := b + maxObletBytes; oblet < s.base()+s.elemsize; oblet += maxObletBytes {
				if !gcw.putFast(oblet) {
					gcw.put(oblet)
				}
			}
		}

		// 获取类型信息
	} else {
		// 这里不重要
	}

	var scanSize uintptr
	for {
		var addr uintptr
        // 获取子对象
        // 整个循环的退出条件就是next不再返回子对象的时候(没东西可继续扫描了)
		if tp, addr = tp.nextFast(); addr == 0 {
			if tp, addr = tp.next(); addr == 0 {
				break
			}
		}

		// 拿到要处理的对象
		scanSize = addr - b + goarch.PtrSize
		obj := *(*uintptr)(unsafe.Pointer(addr))

		// 排除nil和指向当前对象自身的指针
        // 后者属于可以被回收的循环引用,当前对象能不能回收不受这个指针影响
        // 因为如果当前对象不可访问了,那么它的字段自然也是不可能被访问到的,两者均从root不可达
        // 而如果这个指针是可达的,那么当前对象的字段被引用,当前对象也是不需要回收的
        // 所以指向当前对象本身的指针字段不需要处理
		if obj != 0 && obj-b >= n {
			if obj, span, objIndex := findObject(obj, b, addr-b); obj != 0 {
				greyobject(obj, b, addr-b, span, gcw, objIndex)
			}
		}
	}
	...
}

这个函数长归长,条理还是清晰的:

  1. 首先看看对象是否太大要把对象的内存分割成小块交给工作队列里的其他协程并行处理
  2. 接着扫描所有子对象,用
    greyobject
    标记这些对象

因为这个函数本身已经是在扫描了,所以不太会有“跳过”的相关的逻辑,而且你也看到了把这个函数放在不需要扫描子对象的对象上调用时会触发throw,throw会导致程序报错并退出执行。

所以秘密就在
greyobject
里了。看看代码:

// runtime/mgcmark.go
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
	...
	if useCheckmark {
		if setCheckmark(obj, base, off, mbits) {
			// Already marked.
			return
		}
	} else {
		...
		// If marked we have nothing to do.
		if mbits.isMarked() {
			return
		}
		mbits.setMarked()

		...

		// 如果内存被标记为不需要进一步扫描,则会跳过后续的流程(内存会被放进gc扫描的工作队列里等着被取出来扫描)
		if span.spanclass.noscan() {
			...
			return
		}
	}
	// 对象被放进工作队列等待扫描
}

这个函数会先检查对象是否已经被处理过,然后标记对象,接着检查span上的
noscan
标志,设置了的话就返回调用,没有设置说明需要被进一步扫描,于是被放进工作队列,等着
gcDrain
或者它兄弟来处理。

现在我们可以得出结论了,会不会跳过扫描,全部由内存上是否设置
noscan
标志来控制,设置了就可以跳过。

至于在这块内存上的是map还是slice还是struct,没关系。

跳过扫描的具体流程

看了上面的代码,我想信你一定是懵的,跳过具体发生的流程是什么样的呢?

没关系,我们看两个例子就知道了。

第一个例子是一个顶层的全局的可跳过扫描的对象A,介于我们还没说
noscan
会在什么情况下被设置,所以我们先忽略A的具体类型,只要知道它可以跳过扫描即可。

A的扫描流程是这样的:

  1. gc开始运行,先标记root对象
  2. A就是root之一,所以它要么被
    scanblock
    处理要么被
    markrootSpan
    处理
  3. 假设A设置了终结器,又因为A是可跳过扫描子对象的,因此
    markrootSpan
    会直接调用
    scanblock
  4. scanblock
    会调用
    greyobject
    处理内存里的对象
  5. 因为A可跳过扫描,所以
    greyobject
    做完标记就返回了,A不会进入工作队列
  6. A的扫描结束,整个流程上不会有
    scanobject
    的调用

A的例子相对简单,现在我们假设有个不是root对象的对象B,B本身不可跳过扫描,B有一个子对象C可以跳过扫描。我们来看看C的扫描流程:

  1. 因为B并不是root对象,且不可跳过扫描,所以它作为某个root对象的子对象,现在肯定在gc工作队列里
  2. gcDrain
    从队列里拿到了B,于是交给了
    scanobject
    处理
  3. 我们假设B不是很大因此不会被分割(反正分割了也一样)
  4. scanobject
    把每个B的子对象都用
    greyobject
    处理,C也不例外
  5. 因为C可跳过扫描,所以
    greyobject
    做完标记就返回了,C不会进入工作队列
  6. C的扫描结束,整个流程上不会有对C的
    scanobject
    的调用

这样基本涵盖了所有的情况,一些我没单独说的比如“可跳过对象E是不可跳过root对象D的子对象”这样的情况,实际上和情况2没什么区别。

现在对象的子对象扫描是这么跳过的我们也知道了,只剩一个疑问了:noscan标志是怎么设置的?

noscan标志是怎么设置的

在深入之前,我们先来简单看下go的怎么分配内存的。完整讲解恐怕5篇长文也兜不住,所以我做些概念上的精简。

在go里,
mspan
是内存分配的基础单位,一个mspan上可以分配多个大小类似可以被归为一类的对象(比如13字节和14字节的对象都是一类,可以被分配到允许最大存储16字节对象的mspan上)。这个“类型”就叫mpan的
sizeclass
。一个简单的心智模型是把mspan当成一个能存大小相近的对象的列表。

为了加快内存分配,go会给每个线程预分配一块内存,然后按sizeclass分成多份,每份对应一个sizeclass的mspan。这个结构叫
mcache

当然了,总有对象的大小会超过所有mcache的sizeclass规定的范围,这个时候go就会像系统申请一大块内存,然后把内存交给mspan。

存储了span信息的比如sizeclass和noscan的结构叫
spanClass
。这个结构会作为字段存储在mspan的控制结构里。

知道了这些之后,我们就能看懂
s.spanclass.noscan()
了,它的意思就是检查mspan的spanclass信息是否设置了不需要扫描子对象的标志。

而创建spanclass只能用
makeSpanClass
这个函数:

// runtime/mheap.go
type spanClass uint8

func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

现在问题简单了,我们只要追踪谁调用了这个函数就行,以及我们还知道额外的信息:这些调用者还需要从mcache或者系统申请内存获得mspan结构。这样一下范围就收缩了。

按上面的思路,我们很快就找到了go分配内存给对象的入口之一
mallocgc

// runtime/malloc.go
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	...
    // size是指类型的大小
    // typ是需要创建的对象的类型信息,如果只是分配内存,typ要传nil
	// typ是否是空的或者typ是否包含有指针
	noscan := typ == nil || !typ.Pointers()
	if 如果size足够小可以从mspan上分配 {
		if size满足要求可以用tinyallocator分配 {
		} else {
			// 计算sizeclass(size对应到哪一类span)
			spc := makeSpanClass(sizeclass, noscan) // noscan是这里传进去的
			span = c.alloc[spc] // 从mcache拿mspan
            v := nextFreeFast(span) // 从mspan真正拿到可用的内存
			// 后面是把内存内容清零和维护gc信息等代码
		}
	} else {
		// 大对象分配
		// mcache.allocLarge也调用makeSpanClass(0, noscan),然后用mheap.alloc根据span的信息从系统申请内存
		span = c.allocLarge(size, noscan) // noscan是这里传进去的
		// 后面是把内存内容清零和维护gc信息等代码
	}
}

即使sizeclass是一样的,因为noscan的值不一样,两个spanClass的值也是不一样的。对于可跳过扫描的大对象来说,会把为这个对象分配的内存标记为noscan;对于可跳过的小对象来说,会直接把这个小对象放在mcache提前分配的不需要深入扫描的内存区域上。

那么这个
mallocgc
又是谁调用的?答案太多了,因为new,make都会用到它。我们用slice和map做例子看看。

首先是slice。这个非常简单,创建slice的入口是
makeslice

// runtime/slice.go
func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		// NOTE: Produce a 'len out of range' error instead of a
		// 'cap out of range' error when someone does make([]T, bignumber).
		// 'cap out of range' is true too, but since the cap is only being
		// supplied implicitly, saying len is clearer.
		// See golang.org/issue/4085.
		mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

slice中的元素的类型信息被传给了
mallocgc
。如果slice的元素不包含指针,那么slice是可以跳过扫描的。

map比较特殊,跳过扫描的是它的bucket,而bucket外界是看不到的:

// runtime/map.go
// 调用链:makemap -> makeBucketArray -> newarray -> mallocgc
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
	base := bucketShift(b)
	nbuckets := base
	// For small b, overflow buckets are unlikely.
	// Avoid the overhead of the calculation.
	if b >= 4 {
		// Add on the estimated number of overflow buckets
		// required to insert the median number of elements
		// used with this value of b.
		nbuckets += bucketShift(b - 4)
		sz := t.Bucket.Size_ * nbuckets
		up := roundupsize(sz, !t.Bucket.Pointers())
		if up != sz {
			nbuckets = up / t.Bucket.Size_
		}
	}

	if dirtyalloc == nil {
        // t.Bucket.Pointers() 返回键值对中是否包含指针
		buckets = newarray(t.Bucket, int(nbuckets))
	} else {
		// dirtyalloc was previously generated by
		// the above newarray(t.Bucket, int(nbuckets))
		// but may not be empty.
		buckets = dirtyalloc
		size := t.Bucket.Size_ * nbuckets
		if t.Bucket.Pointers() {
			memclrHasPointers(buckets, size)
		} else {
			memclrNoHeapPointers(buckets, size)
		}
	}

	if base != nbuckets {
		// We preallocated some overflow buckets.
		// To keep the overhead of tracking these overflow buckets to a minimum,
		// we use the convention that if a preallocated overflow bucket's overflow
		// pointer is nil, then there are more available by bumping the pointer.
		// We need a safe non-nil pointer for the last overflow bucket; just use buckets.
		nextOverflow = (*bmap)(add(buckets, base*uintptr(t.BucketSize)))
		last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.BucketSize)))
		last.setoverflow(t, (*bmap)(buckets))
	}
	return buckets, nextOverflow
}

func newarray(typ *_type, n int) unsafe.Pointer {
	if n == 1 {
		return mallocgc(typ.Size_, typ, true)
	}
	mem, overflow := math.MulUintptr(typ.Size_, uintptr(n))
	if overflow || mem > maxAlloc || n < 0 {
		panic(plainError("runtime: allocation size out of range"))
	}
	return mallocgc(mem, typ, true)
}

可以看到要是键值对里都不包含指针的话,map就可以被跳过。

所以总结下,只要创建的对象不包含指针(例如数组/切片成员都是不包含指针的类型,map的键值对都不包含指针,结构体所有字段不包含指针)或者只是单纯分配块内存(
makeslicecopy
里分配一块内存然后再把数据copy进去的时候会判断element里包不包含指针,不包含的时候会传nil给
mallocgc
),noscan就会被设置。

现在所有的疑问都解决了:noscan是内存分配时根据类型信息来设置的;能跳过扫描的不只是map,符合条件的类型不管是slice、map还是struct都可以。

优化带来的提升

说了这么多,这个优化带来的提升有多少呢?

看个例子:

var a int64 = 1000

func generateIntSlice(n int64) []int64 {
	ret := make([]int64, 0, n)
	for i := int64(0); i < n; i++ {
		ret = append(ret, a)
	}
	return ret
}

func generatePtrSlice(n int64) []*int64 {
	ret := make([]*int64, 0, n)
	for i := int64(0); i < n; i++ {
		ret = append(ret, &a)
	}
	return ret
}

func BenchmarkGCScan1(b *testing.B) {
	defer debug.SetGCPercent(debug.SetGCPercent(-1)) // 测试期间禁止自动gc
	for i := 0; i < b.N; i++ {
		for j := 0; j < 20; j++ {
			generatePtrSlice(10000)
		}
		runtime.GC()
	}
}

func BenchmarkGCScan2(b *testing.B) {
	defer debug.SetGCPercent(debug.SetGCPercent(-1))
	for i := 0; i < b.N; i++ {
		for j := 0; j < 20; j++ {
			generateIntSlice(10000)
		}
		runtime.GC()
	}
}

我们分别创建20个包含10000个
int64
或者
*int64
的slice(两个类型在x64系统上都是8字节大小),然后手动触发一次GC。为了让结果更准确,我们还在测试开始前禁用了自动触发的gc,而且我们创建的slice的长度和slice里元素的大小都是一样的,所以总体来说结果应该比较接近真实的gc回收内存时的性能。

这是结果:

goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
         │   old.txt   │               new.txt               │
         │   sec/op    │   sec/op     vs base                │
GCScan-8   379.0µ ± 2%   298.0µ ± 2%  -21.51% (p=0.000 n=10)

         │   old.txt    │            new.txt             │
         │     B/op     │     B/op      vs base          │
GCScan-8   1.563Mi ± 0%   1.563Mi ± 0%  ~ (p=0.438 n=10)

         │  old.txt   │            new.txt             │
         │ allocs/op  │ allocs/op   vs base            │
GCScan-8   20.00 ± 0%   20.00 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

内存用量大家都一样,但存指针的时候速度慢了五分之一。slice越大差距也会越大。可见跳过扫描带来的提升还是很大的。

另外少用指针还有助于增加数据的局部性,不仅仅是惠及gc扫描。

如何利用这一优化

最后我们看看如何利用这一优化。

少用指针可以减轻gc压力大家都知道,但有一些“不得不用”指针的时候。

以一个本地cache为例:

type Cache[K comparable, V any] struct {
    m map[K]*V
}

func (c *Cache[K, V]) Get(key K) *V {
    return c.m[key]
}

func (c *Cache[K, V]) Set(key Key, value *V) {
    c.m[key] = value
}

值需要用指针是有两个原因,一是map的元素不能取地址,如果我们想要cache里的数据可以自由使用的话那就不得不用临时变量加复制,这样如果我们想更新值的时候就会很麻烦;二是如果值很大的话复制带来的开销会很大,用cache就是想提升性能呢反过来下降了怎么行。

但这么做就会导致
Cache.m
里的每一个键值对要被扫描,如果键值对很多的话性能会十分感人。

这样看起来是“不得不用指针”的场景。真的是这样吗?考虑到cache本身就是空间换时间的做法,我们不妨再多用点空间:

type index = int

type Cache[K comparable, V any] struct {
    buf []V
    m map[K]index
}

func (c *Cache[K, V]) Get(key K) *V {
    idx, ok := c.m[key]
    if !ok {
        return nil
    }
    return &c.buf[idx] // 可以对slice里存的数据取地址
}

func (c *Cache[K, V]) Set(key Key, value V) {
    idx, ok := c.m[key]
    if !ok {
        // 新建
        c.m[key] = len(c.buf)
        c.buf = append(c.buf, value)
        return
    }
    // 覆盖已添加的
    c.buf[idx] = value
}

我们用一个slice来存所有的值,然后再把key映射到值在slice中的索引上。对于slice的元素,我们是可以取地址的,因此可以简单拿到值的指针,对于值的更新也可以基于这个
Get
拿到的指针,时间复杂度不变,简单又方便。

然后我们再来看,现在
buf

m
都没有指针了,只要
K

V
不包含指针,那么不管我们的cache里存了多少东西对gc来说都只要看看外层的
Cache
对象是否存活就够了。

但是这么做会有代价:

  • Get会稍微慢一点,因为不仅要做额外的检查,还需要从两个不同的数据结构里拿数据,对缓存不友好
  • 存数据进Cache的时候不可避免地需要一次复制
  • Get返回的指针没有稳定性,在底层的buf扩容后就会失效

  • 删除元素会很慢,这怪我们用了slice而且需要维护map里的映射关系,解决方法倒是不少,比如你可以把待删除元素和slice结尾的元素交换这样slice里的其他元素不用移动map也只要遍历一次,又比如你可以再多浪费点内存用墓碑标志来模拟删除或者干脆不提供删除功能(不好做就干脆不做,这是非常golang的做法