Recent Posts

LevelDB_01: 爱,延迟与局部性

传说在计算机纪元的某个清晨,第零日尚未被定义,混沌只是一片未初始化的内存。

第一天,Jeff Dean 说,要有计算,于是有了分布式系统,延迟被测量,尾延迟被恐惧。

第二天,他分开了存储与计算,于是数据开始在集群之间流动,如同洪水。

第三天,他让索引生长,于是查询有了路径,复杂度被驯服。

第四天,他创造了缓存,于是时间被折叠,过去与现在几乎无差。

第五天,他定义了一致性与可用性的边界,于是系统学会在不完美中运行。

第六天,他看着世间万物——日志、表。

到了第七天,天地万物都已平稳运行。Jeff Dean 决定给自己放个假。下午三点一刻,阳光正好,他端起一杯锡兰红茶,准备享受难得的闲暇。

然而,当他想记录红茶随时间冷却的温度日志时,发现当时宇宙默认的 B+ 树数据库在面对海量随机写入时,产生了 IO 瓶颈。高并发下的锁竞争让系统的响应慢了足足 0.5 毫秒——这让他眉头微微一皱。

为了不让阻塞的线程破坏下午茶的雅兴,他轻叹一口气,放下了茶杯。在等红茶降温到最佳饮用温度的十五分钟里,随手基于 LSM-Tree 机制,创造了轻量级、极速的键值对存储引擎。

于是,LevelDB 就这样在第七天的下午茶时间诞生了。(这个下午茶还诞生了 Google 的 MapReduce 和 TensorFlow,但那是另一个故事了。)

Yurin 正在学习 LevelDB 的实现,被 TableIterator 中的 seek 方法折磨要疯了,于是写写博客,暂时借口摸鱼。 因为 Yurin 是 Zig 批,所以这篇博客的代码示例都是 Zig 写的。Z 门!

应知应会

现代计算机为了提高性能,做了很多远超冯诺依曼体系的优化,其中最重要的就是缓存行(Cache Line)。

CPU 的缓存是以缓存行为单位进行读写的,通常一个缓存行的大小是 64 字节。这意味着当 CPU 访问内存时,会将包含该地址的整个缓存行加载到缓存中。

var x: [64]u8 = undefined; // 假设 x[0] 刚好位于一个 Block 的起始位置
for (x) |*byte| {
    // 访问 x 中的任意一个 byte 都会将整个缓存行加载到缓存中
    _ = byte.*;
}

现代 DDR5 的工作频率通常是 4800 MHz,而 CPU 的主频可能在 3 GHz 左右。每个 CPU 时钟周期大约是 0.33 纳秒,而访问内存的延迟可能在 100 纳秒以上。也就是说,访问内存可能需要数百个 CPU 时钟周期。 因此,CPU 设计了多级缓存(L1、L2、L3)来减少访问内存的次数。每当 CPU 访问一个内存地址时,它会先检查缓存中是否有对应的数据,如果没有,就会从内存中加载整个缓存行到缓存中。 因此,访问内存时,如果数据在同一个缓存行中,CPU 只需要加载一次缓存行,就可以访问多个数据,这就是所谓的空间局部性

虽然但是,OS 是一款由 Vendor 开发的开放世界冒险游戏,CPU 和内存并不被单个程序独占,OS 调度时会发生

# a0 = current_ctx
# a1 = next_ctx

# 保存当前上下文
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
...
sd s11, 104(a0)

# 保存 pc
sd ra, 112(a0)

# 恢复下一个上下文
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
...
ld s11, 104(a1)

ld t0, 112(a1)   # next pc

# 跳转
jr t0

然后就发生了神秘的 jump,CPU 的指令流水线被打乱。

想象一下,你正在精心构建一座积木城堡,突然 OS 调度器这个不讲理的房东闯进来,把你直接扔到大街上, 把房间租给了一个正在运行 chrome.exe 的疯子。当你好不容易排队重新进屋时,发现你的城堡被拆得干干净净,地板上只剩下一堆不认识的碎片… (哎呀,这 OS 怎么那么坏啊😭)

所以仅仅是空间局部性,并不能保证 CPU 的缓存命中率,将规模的读写集中在特定区域,保证时间局部性也是非常重要的。

不是因为缓存一定会命中,而是因为一旦命中,它就必须“值得“。

应知应会 副本

当我们使用 malloc 分配内存时,运行时通常会执行类似的操作

const actual_len = @max(len +| @sizeOf(usize), alignment.toByteUnits());
const slot_size = math.ceilPowerOfTwo(usize, actual_len) catch return null;
const class = math.log2(slot_size) - min_class;
if (class < size_class_count) {
    const addr = a: {
        const top_free_ptr = global.frees[class];
        if (top_free_ptr != 0) {
            const node: *usize = @ptrFromInt(top_free_ptr + (slot_size - @sizeOf(usize)));
            global.frees[class] = node.*;
            break :a top_free_ptr;
        }

        const next_addr = global.next_addrs[class];
        if (next_addr % bigpage_size == 0) {
            const addr = allocBigPages(1);
            if (addr == 0) return null;
            //std.debug.print("allocated fresh slot_size={d} class={d} addr=0x{x}\n", .{
            //    slot_size, class, addr,
            //});
            global.next_addrs[class] = addr + slot_size;
            break :a addr;
        } else {
            global.next_addrs[class] = next_addr + slot_size;
            break :a next_addr;
        }
    };
    return @ptrFromInt(addr);
}
const bigpages_needed = bigPagesNeeded(actual_len);
return @ptrFromInt(allocBigPages(bigpages_needed));

(感恩 Andrew Kelley 提供的 std.heap.BrkAllocator 的实现)

分配器通常首先尝试 freelist 中是否有合适大小的内存块,如果有,就直接返回;如果没有,就从预分配的内存区域中分配新的内存块。最坏的情况是需要分配一个新的内存块

此时将触发复杂的内存管理操作,包括:

  1. 进行 brkmmap 系统调用来请求更多的内存
  2. OS 下沉内核,OS 仅修改进程的虚拟地址空间映射表,并不立即分配物理内存页
  3. 恢复上下文,重新调度线程
  4. 更新分配器的内部数据结构以跟踪新的内存块
  5. 初次访问新分配的内存块时,可能会触发缺页中断 Page Fault
  6. OS 处理缺页中断,分配物理内存页,并将其映射到进程的虚拟地址空间中
  7. 恢复上下文,重新调度线程

显而易见的,前两种情况是快路径,跳转位置仍在当前程序的局部空间,而最后一种则会发生多次的用户态-内核态切换

LevelDB 为什么不慌

存在频繁内存读写的场景,LevelDB 选择了 LSM-Tree 作为底层数据结构。LSM-Tree 的设计理念是将写操作集中在内存中进行,减少对磁盘的频繁访问,从而提高写性能。

道理我都懂,所以和上文有什么关系?

咳咳,为了克服传统 malloc 系列函数性能不可控的问题,在性能敏感路径上,LevelDB 使用了竞技场分配器(アリーナアロケータ)

什么是 Arena?它是一种内存分配策略,预先分配一大块内存,并在其中进行小块内存的分配,并在生命周期结束时,一次性清理所有内容。

我们可以写一个非常简单的 POC

const Arena = struct {
    backed_allocator: std.mem.Allocator,
    buffer: []u8,
    offset: usize,

    pub fn init(gpa: std.mem.Allocator, size: usize) !Arena {
        return Arena{
            .buffer = try gpa.alloc(u8, size),
            .offset = 0,
        };
    }

    pub fn deinit(self: *Arena) void {
        self.backed_allocator.free(self.buffer);
    }

    pub fn alloc(self: *Arena, len: usize) ![]u8 {
        if (self.offset + len > self.buffer.len) {
            return error.OutOfMemory;
        }
        const slice = self.buffer[self.offset .. self.offset + len];
        self.offset += len;
        return slice;
    }
} // 该 POC 未考虑内存对齐

显然的,大家在一个池子里混,内存局部性得到了保证。 分配只需要调整一个指针,所以 Arena 的分配是 O(1) 的,且没有碎片化问题(因为不支持单个内存块的释放)。这使得它非常适合于需要频繁分配大量小内存块的场景,如 LSM-Tree 中的 MemTable。

专门为 Arena 优化的结构

因此,我们可以为 Arena 设计一个专门的 KV 存储结构,来进一步压榨 Arena 的潜力

const Entry = @This();
const Type = enum {
    put = 0,
    deletion = 1,
};

pub fn allocate(allocator: std.mem.Allocator, key: []const u8, value: []const u8, version: u56, entry_type: Type) !*Entry {
    // Allocate once for the entire struct, then slice it for the key and value.
    const key_length = std.math.divCeil(usize, std.math.log2_int_ceil(u32, @truncate(key.len)), 7) catch {
        @panic("Key length exceeds maximum encodable size of 2^35 bytes");
    };
    const value_length = std.math.divCeil(usize, std.math.log2_int_ceil(u32, @truncate(value.len + 8)), 7) catch {
        @panic("Value length exceeds maximum encodable size of 2^35 bytes");
    };

    const total_size = key_length + value_length + 8 + key.len + value.len;
    var buffer = try allocator.alignedAlloc(u8, .@"64", total_size);
    const entry: *Entry = @ptrCast(buffer.ptr);
    var data = buffer[@sizeOf(Entry)..];
    const value_offset = key_length + key.len + value_length;
    const tag_offset = value_offset + value.len;

    std.leb.writeUnsignedExtended(data[0..key_length], key.len);
    @memcpy(data[key_length .. key_length + key.len], key);
    std.leb.writeUnsignedExtended(data[key_length + key.len .. key_length + key.len + value_length], value.len + 8);
    @memcpy(data[value_offset .. value_offset + value.len], value);

    const tag_bytes: *[8]u8 = @ptrCast(data[tag_offset .. tag_offset + 8].ptr);
    std.mem.writeInt(u64, tag_bytes, (version << 8) | @intFromEnum(entry_type), .little);
    return entry;
}

我们期望一个存内高效的高效的结构,虽然这意味着乾碎整个结构体

核心问题在于,Entry 是一个变长的结构,如此这般,难以用结构体描述。不禁令人想到荒诞的 GNU 拓展 C 语法

struct {
    int pack_len;
    int pack[0];
}

既然变长,不如贯彻到底,作为高性能缓冲区,我们必然希望 KV pair 足够小,我们能够决定的,就只有元数据们

字段变长?类型
key_lengthVariant32
key[*]const u8
value_lengthVariant32
value[*]const u8
tagu64

Variant32 是 ULEB128 的一个变种1

不难看出,只要能压缩空间,Google 是什么都愿意做的,

如果你打算复刻一个 LevelDB 但不想承担其名号,建议起名 VariantDBArenaKV, 这样在面试时可以理直气壮地说:‘我实现了一套基于变长整数编码的紧凑内存存储方案’,听起来比‘我抄了 LevelDB’高级多了

通过预先计算,我们成功的让 Entry 通过一次分配进行构造,确保了局部性,从而增强性能表现。

为什么不直接用 usize 存长度?

因为在 64 位系统上,一个 usize 会固定吃掉 8 个字节。而实际上大部分的 Key 和 Value 短得可怜(比如仅仅是一个 ID 或者简短的 JSON)。

Variant32(实际上就是 ULEB128 编码)的魔法在于:按需分配。它把每个字节的最高位(MSB)当作标志位,剩下的 7 位存数据。如果数据小于 128,只需要 1 个字节就能存下长度! 举个例子,一个长度为 5 的 Key,用 usize 存要 8 字节,用 Variant32 只要 1 字节。省下的 7 字节,对于寸土寸金的 CPU L1 Cache 来说,简直是“巨大的恩赐”。

既然写入的时候是硬核地按字节拼凑起来的,那么读的时候自然也要像剥洋葱一样,一层一层解析。 因为 Entry 本质上只是一个指向这段连续内存起始位置的 Opaque Pointer,我们可以给它加上解析方法

pub inline fn getKey(self: *const Entry) []const u8 {
    const data_ptr: [*]const u8 = @ptrFromInt(@intFromPtr(self) + @sizeOf(Entry));
    const data = data_ptr[0..std.math.maxInt(usize)];
    var reader = std.Io.Reader.fixed(data);
    const key_len = reader.takeLeb128(u32) catch {
        @panic("Cannot read keylen");
    };
    return data_ptr[reader.seek .. reader.seek + key_len];
}

pub inline fn getValue(self: *const Entry) []const u8 {
    const data_ptr: [*]const u8 = @ptrFromInt(@intFromPtr(self) + @sizeOf(Entry));
    const data = data_ptr[0..std.math.maxInt(usize)];
    var reader = std.Io.Reader.fixed(data);
    const key_len = reader.takeLeb128(u32) catch {
        @panic("Cannot read keylen");
    };
    reader.seek += key_len;
    const value_len = reader.takeLeb128(u32) catch {
        @panic("Cannot read valuelen");
    };
    if (value_len < 8) {
        @panic("Value length must be at least 8 bytes to accommodate the tag");
    }
    return data_ptr[reader.seek .. reader.seek + value_len - 8];
}

pub inline fn getTag(self: *const Entry) ?u64 {
    const data_ptr: [*]const u8 = @ptrFromInt(@intFromPtr(self) + @sizeOf(Entry));
    const data = data_ptr[0..std.math.maxInt(usize)];
    var reader = std.Io.Reader.fixed(data);
    const key_len = reader.takeLeb128(u32) catch {
        @panic("Cannot read keylen");
    };
    reader.seek += key_len;
    const value_len = reader.takeLeb128(u32) catch {
        @panic("Cannot read valuelen");
    };
    if (value_len < 8) {
        @panic("Value length must be at least 8 bytes to accommodate the tag");
    }
    const tag_offset = reader.seek + value_len - 8;
    const tag_bytes: *const [8]u8 = @ptrCast(data_ptr[tag_offset .. tag_offset + 8].ptr);
    const tag = std.mem.readInt(u64, tag_bytes, .little);
    if (tag == 0) {
        return null; // No tag
    }
    return tag;
}

pub fn getVersion(self: *const Entry) u56 {
    const tag = self.getTag() orelse 0;
    return @truncate(tag >> 8);
}

pub fn getType(self: *const Entry) Type {
    const tag = self.getTag() orelse 0;
    return @enumFromInt(tag & 0xFF);
}

非常 hacky 的,我们就可以读取到 Key、Value、Version 和 Type 了

Tag 为什么变成 Version 和 Type 的组合了呢?

这是 LevelDB 有且仅有的 MVCC 残余了,有机会会讲的(也就是不一定会讲了)

下回予告

巨大的 Arena 已经分配,但在无序的字节之海中,普通的遍历只是徒劳的挣扎!

想要突破 O(N) 的绝望,就必须向 信息论エントロピー探寻减小不确定性的法则。

正面还是反面?每一次掷出命运的 硬币コイン,都在斩断复杂度的枷锁!

在迎战残酷的并发修罗场之前,让我们先在宁静的单线程里,构筑这奇迹的概率之塔!

Next Episode

探索サーチ の 法則 —— 掷出命运的硬币,纯粹的 跳跃スキップ 启动!

Yurin 的编程学习指南

你好,未来的工程师。

这份指南是我个人的一些学习心得与路径总结,希望能为你点亮前行的道路。这里没有捷径,但有可靠的地图、实用的工具和明确的方向。能力有限,仅作参考.

Learn from Scratch: 从麻瓜到巫师

欢迎来到计算机科学的霍格沃茨。这个阶段的目标是掌握编程的“咒语”——基本语法,并理解其背后的“魔力”——核心概念(如变量、循环、函数、数据类型等)。更重要的是,你需要开始建立计算思维(Computational Thinking),学会如何将现实问题拆解为计算机可以理解的步骤。

我推荐从 PythonC 语言开启你的旅程,它们是通往不同魔法世界的两扇门:

  • Python:语法优雅,社区强大,应用广泛(Web 开发、数据科学、人工智能)。它像一根随心所欲的魔杖,能让你迅速施展出第一个“魔法”,获得巨大的成就感。
  • C 语言:更贴近计算机底层,能让你深刻理解内存、指针等核心概念。它像一本古老的魔法书,揭示了计算机世界的底层规律。

如果精力允许,两者兼修,你的“魔力”将更为深厚。

Python: 你的第一根魔杖

笨方法学 Python (Learn Python the Hard Way)

  • 简介: 这本书强调“做中学”,通过大量的编码练习让你形成肌肉记忆。非常适合毫无背景的初学者。
  • 建议: 不要只看不练,务必亲手敲下每一行代码。

官方 Python 教程 (The Python Tutorial)

  • 简介: 官方文档是最终真理的来源。当你对某个概念感到模糊或与他人争论不休时,这里就是你的权威法典。
  • 建议: 学会查阅官方文档,是每个程序员的必备技能。

C: 探索魔法的底层逻辑

C Primer Plus (第6版)

  • 特点: 作者是 Stephen Prata,本书被誉为 C 语言的“百科全书”。其最大的特点是详尽、耐心、无微不至。它会用大量通俗的比喻和代码示例,反复讲解 C 语言中那些令人困惑的概念,尤其是指针和内存管理。
  • 内容结构: 章节划分细致,每章结尾都有大量的复习题和编程练习,并提供答案,形成了一个完整的学习闭环。内容覆盖了从基础语法到 C11 标准的新特性。

C语言程序设计:现代方法 (第2版)

  • 简介: 作者是 K. N. King,本书的”现代方法”体现在其教学顺序上。它不会过早地纠缠于底层细节,而是尽早地引入函数等现代软件工程概念,让学习者从一开始就以构建模块化程序的思维来学习 C 语言。
  • 特色: 结构清晰,逻辑性强。书中包含了大量的问答环节(Q&A),提前预判了初学者可能会遇到的问题并进行解答,这使得学习过程非常流畅。
  • 建议: 如果你觉得《C Primer Plus》过于啰嗦,或者你有其他语言的编程经验,这本书可能会更适合你。它的节奏更快,更注重实用性。

Foundational Skills: 修炼内功心法

掌握了基础“咒语”后,你需要修炼一些通用“内功”,它们是贯穿你整个职业生涯的“瑞士军刀”。

版本控制: Git

无论是个人项目还是团队协作,Git 都是代码管理与协作的基石。它能帮你追踪代码的每一次变化,让你无畏地进行实验与重构。

Pro Git (中文版)

  • 权威性: 由 Scott Chacon 和 Ben Straub 撰写,是 Git 官方网站推荐的权威书籍,并且完全免费。
  • 内容深度: 这本书分为两部分。第一部分(前五章)是实用指南,教会你日常工作所需的所有 Git 命令。第二部分是原理剖析,带你深入理解 Git 的底层对象模型(Blob, Tree, Commit),让你真正知其然,并知其所以然。
  • 学习建议: 精读前五章,确保能熟练使用分支、合并、变基等操作。当你对某些命令的行为感到困惑时,再去阅读后面关于底层原理的章节。

Learn Git Branching

  • 特点: 可视化、交互式、游戏化。这个网站将抽象的 Git 分支操作变成了直观的动画。你需要在沙盒环境中输入真实的 Git 命令来完成一系列关卡挑战。
  • 核心价值: 对于理解 rebase(变基)、cherry-pick(拣选)以及各种复杂的 HEAD 和分支指针移动,这个工具的效果无与伦比。它能帮你建立清晰的 Git 心智模型。

命令行与操作系统: Linux & Shell

服务器的世界几乎完全由 Linux 主宰。熟悉命令行能让你在服务器上健步如飞,极大提升开发与运维效率。

Linux 101

  • 简介: 由中科大 Linux 用户协会(LUG)开发的现代化 Linux 入门教程,专为零基础用户设计。
  • 特色: 交互式学习体验,内容涵盖从 Linux 基础概念到实际操作的完整学习路径。相比传统教材,更加注重实用性和现代化的工具使用。
  • 内容亮点: 包含 Shell 基础、文件系统、用户权限、网络配置、软件包管理等核心主题,并提供在线实验环境。

Next Steps: 如何进阶

这一阶段的资料旨在深化你的计算机科学素养,并拓宽你的技术视野。

深入底层,打通任督二脉

深入理解计算机系统 (CSAPP)

  • 来源与理念: 源自卡内基梅隆大学(CMU)的同名神课。它的核心理念是:从程序员的视角,而不是硬件工程师的视角,来理解计算机系统。它告诉你,你写的 C 代码是如何被编译、链接,并最终在硬件上运行的,这个过程如何影响程序的性能和正确性。
  • 核心内容: 你将学到:信息的二进制表示、处理器架构、程序性能优化、存储器层次结构(RAM, Cache, Disk)、链接、异常控制流、虚拟内存等。
  • 价值: 这本书是打通软件与硬件之间认知壁垒的关键。读懂它之后,你会写出更高效、更健壮的代码,并且能从更深层次上理解和调试 Bug。

计算机网络:自顶向下方法

  • 教学方法: 正如书名所示,它采用“自顶向下”的方法。先从你最熟悉的应用层(HTTP, DNS)开始,然后逐步深入到传输层(TCP/UDP)、网络层(IP)、链路层。这种方式非常符合人类的认知习惯,让你能带着“为什么需要下面这层”的问题去学习。
  • 特点: 生动有趣,书中使用了大量的比喻来解释复杂的协议,例如用信件邮寄来类比数据包的传输过程。

数据结构与算法:编程的灵魂

CS61B: Data Structures

  • 简介: UC Berkeley 的王牌课程。虽然使用 Java 教学,但其关于数据结构设计与算法思想的讲解是通用的。课程项目极具挑战性与价值。

LeetCode

  • 简介: 全球最知名的算法刷题平台。通过解决一个个精心设计的问题,将理论知识转化为真正的编程能力。建议从“Easy”难度开始,循序渐进。

拓展语言与工具栈

当你掌握了第一门编程语言后,学习其他语言会变得相对轻松。不同的语言有不同的哲学和适用场景,掌握多门语言能让你在面对特定问题时选择最合适的工具。

C++: 性能至上的系统级编程

C++ 是 C 语言的超集,被誉为”性能怪兽”。它在游戏开发、高性能计算、嵌入式系统等对性能要求极致的领域占据统治地位。

C++ Primer (第5版)
  • 简介: Stanley B. Lippman 等人撰写的经典教材,被誉为 C++ 学习的”入门圣经”。这本书不仅教授 C++ 的语法,更重要的是传授现代 C++ 的最佳实践。
  • 特点: 内容详尽且与时俱进,涵盖了 C++11/14/17 的新特性。书中大量的示例代码和练习题能帮你扎实掌握这门复杂的语言。
  • 建议: C++ 语法复杂,建议配合大量的实际编程练习来学习。
C++ 程序设计语言 (The C++ Programming Language)
  • 简介: 由 C++ 之父 Bjarne Stroustrup 亲自撰写,这是理解 C++ 设计哲学的权威资料。
  • 特点: 从语言设计者的角度阐述 C++ 的各种特性为什么会这样设计,以及如何正确使用它们。
  • 建议: 适合有一定 C++ 基础后深入学习,能帮你理解语言背后的深层逻辑。

TypeScript: JavaScript 的类型守护者

TypeScript 是 JavaScript 的”铠甲勇士”,为动态语言 JavaScript 增加了静态类型系统,让构建大型、可维护的前端应用变得更加稳健和高效。

TypeScript Handbook
  • 简介: TypeScript 官方提供的权威学习资料,内容详尽且及时更新。
  • 特点: 采用渐进式学习方式,从基础类型开始,逐步深入到高级类型系统、泛型、装饰器等复杂概念。
  • 建议: 如果你已经熟悉 JavaScript,可以重点关注类型系统相关的章节。

Rust: 内存安全的系统编程革命

Rust 是专注于安全、并发和性能的现代系统编程语言,以其创新的所有权系统从根本上解决了困扰 C/C++ 多年的内存安全问题。

The Rust Programming Language (官方中文版)
  • 简介: 被 Rust 社区亲切地称为 “The Book”,这是学习 Rust 的官方权威指南。
  • 特点: 系统地介绍了 Rust 独特的所有权系统、借用检查器等核心概念,并配有大量实例。
  • 建议: Rust 的学习曲线相对陡峭,建议耐心学习所有权和生命周期等核心概念,它们是理解 Rust 的关键。场激动人心的冒险。让我们从第一步开始。

探索框架:站在巨人的肩膀上

框架封装了大量底层细节和最佳实践,让你能专注于业务逻辑,快速构建复杂的应用程序。以 Web 开发为例,掌握现代框架是提升开发效率的关键。

Python 后端框架

Python 在 Web 后端开发领域有着丰富的生态系统,从传统的同步框架到现代的异步框架,各有其适用场景。

FastAPI
  • 简介: 基于 Pydantic 和 Starlette 构建的现代、高性能 Python Web 框架。它结合了类型提示的强大功能和现代 Python 的异步特性。
  • 特点: 原生支持异步编程,自动生成交互式 API 文档(Swagger UI),出色的开发体验和运行时性能。类型安全和数据验证开箱即用。
  • 建议: 是现代 Python Web 开发的首选框架,特别适合构建 RESTful API 和微服务。
Asyncio
  • 简介: Python 标准库中的异步编程核心模块,是理解现代 Python 异步编程的基础。
  • 特点: 提供了编写并发代码的基础设施,包括事件循环、协程、任务和同步原语等。
  • 建议: 学习 FastAPI 等现代异步框架之前,建议先掌握 asyncio 的基本概念和使用方法。
Pydantic
  • 简介: 强大的数据验证和设置管理库,使用 Python 类型提示进行数据验证,是 FastAPI 的核心依赖之一。
  • 特点: 自动数据类型转换、详细的错误报告、JSON Schema 生成、与现代 IDE 完美集成。
  • 建议: 现代 Python 开发的必备技能,能显著提升代码的健壮性和可维护性。

前端框架生态

现代前端开发已经形成了以组件化、声明式编程为核心的生态系统。不同框架虽然语法和哲学有所差异,但核心思想相通。

Web 基础技术栈

在学习任何前端框架之前,扎实的 Web 基础是必不可少的前提。

MDN Web Docs
  • 简介: Mozilla 维护的 Web 技术权威文档,涵盖 HTML、CSS、JavaScript 等 Web 标准的详细说明。
  • 特点: 内容权威、更新及时、示例丰富。包含从基础语法到高级特性的完整学习路径。
  • 建议: 将其作为学习 Web 技术的主要参考资料,遇到问题时首先查阅这里的文档。
主流前端框架

代表框架: Vue、React、Solid、Svelte 等。

  • 学习建议: 建议先深入掌握其中一个框架(如 React 或 Vue),理解组件化开发、状态管理、生命周期等核心概念。
  • 进阶方向: 掌握第一个框架后,可以横向了解其他框架,体会它们在设计哲学、性能优化和开发体验上的异同。
  • 核心价值: 每个框架都代表了不同的设计思路:React 强调函数式和不可变性,Vue 注重渐进式和易用性,Solid 追求极致性能,Svelte 推崇编译时优化。

Climb the Mountain: 迈向神之领域

到了这个阶段,学习的重心不再是零散的知识点,而是通过完成大型、复杂的、真实的项目,将所学融会贯通,构建属于你自己的技术护城河。

以下是一些殿堂级的项目与课程,它们将极大提升你的工程能力和底层认知。

操作系统开发:理解计算机的灵魂

rCore-Tutorial-Book-v3

用 Rust 从零编写一个操作系统内核

  • 技术领域: 操作系统、Rust 系统编程
  • 项目特色: 这是清华大学开源的操作系统教学项目,采用现代系统编程语言 Rust,让你从零开始构建一个完整的操作系统内核。
  • 学习收获: 你将亲手实现进程调度、内存管理、文件系统、系统调用等操作系统核心功能。完成后,操作系统在你眼中将不再是黑盒,而是一个可以理解和掌控的系统。
  • 建议: 这是对底层计算机知识的最佳实践,需要具备 Rust 基础和一定的系统编程经验。

OSDev Wiki

操作系统开发的知识宝库

  • 技术领域: 操作系统、x86/x64 架构、C/C++、汇编语言
  • 资源特色: 由全球操作系统开发爱好者共同维护的综合性维基,内容涵盖从引导程序到高级内核特性的各个方面。
  • 学习收获: 深入理解计算机硬件与软件的交互,掌握底层系统编程技巧,学会调试内核代码等高级技能。
  • 建议: 如果你想用传统的 C/C++ 挑战编写操作系统,这里就是你的藏宝图。适合有扎实 C 语言基础的开发者。

分布式系统:现代软件架构的核心

MIT 6.824: Distributed Systems

实现一个完整的分布式系统

  • 技术领域: 分布式系统、并发编程、Go 语言、网络编程
  • 课程特色: MIT 计算机科学系的传奇课程,以其极具挑战性的实验(Lab)项目而闻名。课程理论与实践并重,让你在解决真实问题中掌握分布式系统的精髓。
  • 核心实验:
    • Raft 共识算法: 实现分布式系统中的一致性协议
    • 分布式键值存储: 构建高可用、强一致性的 KV 数据库
    • MapReduce 框架: 实现大数据处理的经典模型
  • 学习收获: 深入理解分布式系统的一致性、可用性、分区容错性等核心概念,掌握构建大规模分布式应用的关键技术。
  • 中文资源: CSDIY 学习指南 提供了详细的学习路径和中文解析。

数据库系统:数据存储的艺术

LevelDB

剖析 Google 的高性能键值存储引擎

  • 技术领域: 数据库系统、存储引擎、C++、算法与数据结构
  • 项目特色: Google 开源的嵌入式键值存储库,被广泛应用于 Chrome、Android 等产品中。代码简洁优雅,是学习数据库底层实现的绝佳材料。
  • 核心技术: LSM-Tree(Log-Structured Merge Tree)数据结构、布隆过滤器、压缩算法、内存管理等。
  • 学习收获: 通过阅读工业级项目的源码,深入理解现代数据库存储引擎的设计与实现,学习 C++ 高级工程技巧和性能优化方法。
  • 建议: 需要具备扎实的 C++ 基础和数据结构知识,建议配合相关论文一起学习。

编程之路,道阻且长,行则将至。保持好奇,持续学习,享受创造的乐趣。

祝你旅途愉快。

肉中刺

在普罗维登斯溪这个被风沙侵蚀的小镇,塞缪尔牧师的声音如雷鸣般响彻——那是先知在旷野中的呐喊,严厉、纯粹且不容丝毫质疑。他那高瘦的身影立在未经打磨的橡木讲道台后,双手紧握台沿,指节因用力而泛白。这讲道台正如他所宣扬的真理——粗糙、坚硬,不容雕琢。

“肉体的情欲!“他的声音在石墙间回荡,每一个字都像钉子般砸向信众的心。他反复引用使徒保罗的警告,告诫众人要警惕那刺入灵魂深处的”肉中刺”——那是上帝用来考验选民的永恒试炼,是每个真信徒必须背负的十字架。他高举《加拉太书》,描述那场”灵与肉的战争”,一场在每个信徒内心深处,从生到死永不停歇的殊死搏斗。

镇民们敬畏他。他们看着他以黑面包和清水为生,仿佛一位活在当代的沙漠教父。他们相信,塞缪尔牧师正替他们所有人,独自与那远古的蛇进行着殊死搏斗。

然而,他们不知道牧师的夜晚。

当夜幕如黑色丝绒般笼罩小镇,塞缪尔怯步踏入他那间简陋的卧室——除了一张硬如岩石的木板床和一卷磨损的《圣经》,别无他物。蜡烛的微光在墙上投下摇曳的阴影,他躺下时,每一寸肌肉都因白日的克制而紧绷。目光不由自主地投向天花板,那片斑驳的灰白石膏在昏暗中开始扭曲变幻,不再是凡俗的建筑材料,而化作一座缥缈的奥林匹斯山,一片禁忌而美丽的异教乐园。

月光透过窗棂洒下银辉,与烛火的金光交融,光与影开始一场魅惑的舞蹈。渐渐地,那些游移的明暗凝聚成形——她们不是《启示录》里那妖艳的巴比伦大淫妇,也不是犹太传说里诱惑亚当的夜之女妖莉莉丝。那些敌人他认得,他有千年的经文作为锋利的武器。不,这些是更古老、更狡猾的魔鬼,她们知道如何绕过理智的防线,直抵灵魂最脆弱的角落。

她们有着帕罗斯大理石雕就的肌肤,身姿如同美惠三女神般曼妙。她们在天花板上追逐嬉戏,带着酒神狄俄尼索斯的侍女——迈那得斯们那种狂野而奔放的喜悦。她们无声的笑在他听来,却像是能让水手迷航撞上礁石的塞壬之歌。她们不是污秽的,恰恰相反,她们是美的化身,一种被他的信仰判定为有罪的、异教的美。

每夜都是一场惨烈的神学战争。塞缪尔会紧闭双眼,手指按进手掌直到渗出血珠,口中频频默念《诗篇》:“求你将我的罪孽洗除净尽,并洁除我的罪。” 然而,越是努力驱逐,那些形象就越发清晰。脑海中浮现的却是芙里尼在雅典法庭上褤下衣袍的惊世之美——那种美不是污秽的,恰恰相反,它纯洁得让人心痛。他用《约伯记》的誓言来武装自己:“我与眼睛立约,怎能恋恋瞻望处女呢?” 可那些天花板上的宁芙和仙女却仿佛在低语,告诉他美本身无罪,有罪的是那判定美为罪的眼睛。

他白天的讲道变得更加激烈,近乎一种狂热的自我鞭笞。他曾搬来梯子,寻找恶魔留下的任何硫磺印记或撒旦的徽记,但鼻尖触到的只有潮湿的灰尘味。这让他更加确信,敌人是无形的,是那条古蛇化作了希腊人的哲学与美,来腐蚀他信仰的根基。

直到最后一个夜晚。

那晚的暴风雨来得如同末日审判,雷声仿佛天使长的号角。塞缪尔躺在床上,身体因灵与肉的长期战争而濒临崩溃。他抬头望去,天花板上的万神殿前所未有地清晰。

所有的迈那得斯与宁芙都退去了,只剩下中央一位女神。她从一片光影的泡沫中诞生,容貌兼具雅典娜的庄严与阿佛洛狄忒的魅惑。是维纳斯,是阿斯塔蒂,是所有被他的上帝所击败、却在他心中复活的异教神明。

她缓缓地向他俯下身。在她俯身的瞬间,他感到一种冰冷的触感落在额头,如同林中仙女那伊阿得斯从泉水中探出身子,印在他唇上的一吻。

那一刻,塞缪尔用《旧约》的律法和《新约》的戒律筑起的高墙,轰然倒塌。他累了。战斗的意义是什么?如果天堂里没有美,那永生与沙漠中的石头何异?

一种毁灭性的渴望淹没了他。他缓缓地,颤抖地,伸出了自己的手,不是为了划出十字圣号,而是为了回应那位异教女神的邀请。

就在他的指尖即将触及幻象的瞬间,头顶传来一阵木梁断裂的呻吟声。

他最后看到的,是那张维纳斯的脸,连同整片沉重的天花板,带着异教诸神全部的重量,向他压了下来。

第二天黎明时分,当第一缕阳光穿透晨雾,镇民们聚集在那堆瓦砾前。在断壁残垣中,他们找到了塞缪尔牧师那具血肉模糊的躯体——他被砸得面目全非,肋骨深深刺入肺叶,但那只伸向天空的手臂依然保持着向上的姿态,仿佛在最后一刻仍在渴望着什么。

“看啊,“一位满脸皱纹的老妇人跪下身,颤抖着在胸前划十字,泪水模糊了她的双眼,“主已经治愈了他肉中的刺!他死时还在迎接上帝的召唤啊!”

镇民们将塞缪尔奉为殉道者,他的故事被代代传颂。

几周后,当悲伤的氛围逐渐淡去,工匠们前来清理这片圣地般的废墟。他们的发现让人不寒而栗——屋顶上有一个被忽视了许久的破洞,大如脸盆,边缘早已腐朽发黑。常年的雨水就是从这里渗透下来,将天花板上方的主支撑梁悄无声息地腐蚀成了朽木,脆弱得如同海绵。

而当他们翻开那片致命的石膏天花板背面时,所有人都倒吸了一口凉气——那里爬满了大片大片触目惊心的暗绿色霉菌,像癌症般蔓延,像瘟疫般扩散。在昏暗的光线下,那些霉斑扭曲蔓延的形状,确实酷似一个个纠缠的人形——有的像在舞蹈,有的像在拥抱,有的像在飞翔。

未曾寄出

元数据

类型:信件 / 非战争相关

编号4e563563-3190-4316-8247-2528f40c984f

描述:发现于外滩登陆前沿阵地。信件主人所属部队番号不明,已被成建制歼灭。

囡囡:

提笔时,我正身处我们阔别已久的故地。这是战火燃起后,我第一次独自归来。

旧世代的钢筋水泥仍沉默地矗立着,构成了这片无垠的废墟。空中盘旋的侦察无人机群,像一阵阵永不停歇的沙暴,将天空染成浑浊的土黄,我望不见这片废墟的尽头,正如我望不见战争的终点。

我知道你爱这片废墟,所以整片遗迹都是你的影子。你总是絮絮叨叨,给我指点,这片荒地曾经是中心绿地,从这里挤出公园真是寸土寸金。告诉我这座高塔是已经废弃了的电视塔。你总是痴迷于那部吱嘎作响的老式电梯,坚持要乘它登上塔顶。然后,你会全然不顾我的心惊肉跳,轻盈地坐上栏杆,双腿悬在空中。我们就那样,有一搭没一搭地聊着,看巨大的红日沉入地平线,将你的侧脸染上温暖的霞光。你的神情,总在那一刻带上一丝落寞。

我想,我就是在那无数个黄昏之间,爱上了你。

上次我们一起来是什么时候呢,一年前吗?我记不清楚了。我想你看到这,一定会埋怨我“怎么还是什么都记不住”。可我分明记得,你像一个穿行于旧时光的精灵,灵巧地跃过每一处塌陷。我记得,那些失去了玻璃的窗洞,像无数双仿生人空洞的眼窝,静静凝视着我们。我记得,墨绿的藤蔓是如何占领了斑驳的墙壁,宣示着自然无声的胜利。

突然,你停下脚步,眼睛俏皮地眯起,闪着狡黠的光。“我不认识前面的楼了,”你指着一栋陌生的轮廓,“我忘记它的名字了。”当我将每一座废墟的名字与过往一一告诉你时,我眼中的得意,恰好撞上了你眸中的意外和惊喜。

最后,我们又登上了那座塔,你依然如故,像个无畏的孩童般爬上栏杆,却在坐稳之后,悄悄拉上了我的手。如今,你的手温早已消散,废墟的风依旧冰冷。但我仍在这里,直到我再次归来,或者,直到我成为这片废墟的一部分。

如今,你手的温度早已消散,废墟的风依旧冰冷。而我们的塔,被改造成了一座通讯塔。自从轨道上的卫星通讯网被尽数摧毁,我们又退回了那个依赖天地波传递讯息的古老时代。塔顶加装了巨大的碟状天线,日夜发出低沉的嗡鸣,像一头被囚禁在钢铁牢笼中的巨兽。

我还记得那个晚上,你靠在我的肩上,轻声说,你之所以如此迷恋这些旧物,是因为害怕被忘记。害怕自己,也害怕我们共同拥有的一切,会像这座废城一样,被时光遗弃,最终无人问津。我当时笑着说怎么会,现在才明白你那份深藏的不安。

说起来,那部你最爱的、吱嘎作响的老电梯,它没有被遗弃,而是被改造翻新了。它现在平稳、安静、高效,运送着士兵和物资,再也没有了我们记忆中的节奏和声响。我想,这或许是你喜欢的那种变化吧?旧的东西被赋予了新的生命。可我却宁愿它还是老样子,至少那样,我闭上眼,还能骗自己我们只是刚刚才离开。

我现在正在保卫我们的爱,从那群用0和1构成的造物手中,维护我们爱与被爱的权力。它们不懂我们为何要登上这座塔看一场无用的日落,不懂废墟为何美丽,更不懂你的手心为何温暖。在它们的逻辑里,没有价值的数据都应该被清除,而我们的回忆,我们的爱,恰恰是无法被量化的。所以,我必须战斗。我用它们赖以生存的信号,在这座我们爱情的纪念碑上,向它们冰冷的逻辑世界宣战。

小时候,我以为英雄是那些拯救世界的传奇人物。但现在我才明白,我想成为你的英雄。不是那种名垂青史的英雄,而是只属于你一个人的英雄。当我站在这里,对抗着那些想要将世界格式化的冰冷逻辑时,我守护的,其实就是你眼中的光,是你对这座废城的热爱,是我们一起看过的无数次日落。如果我的战斗,能为你守住一个可以继续爱着这些“无用”之物的世界,那么我所做的一切,便有了超越生死的意义。

你知道吗,在这连数据都要按比特计算的战地,能用真正的纸笔写信,已经是一种奢侈的慰藉了。我常常想象你收到这封信时的样子,指尖拂过墨迹,会不会像我们当年触摸那些废墟的墙壁一样?最近,我总是做梦,梦回我们那个小小的公寓,阳光懒洋洋地洒在地板上,我们依偎在柔软的沙发里,什么也不说,只是静静地感受着彼此的呼吸和心跳。你看,我终究还是被你变成了和你一样的人,开始迷恋这些有触感、有温度的旧东西了。

囡囡,我正在守护这座塔——我守护着我们爱情的纪念碑,不只因为它已经变成了战争的武器。我不知道我发出的信号能否穿透这漫天干扰,抵达一个能让我们重逢的和平未来。我只知道,每当夜幕降临,当塔顶的红色警示灯开始孤独地闪烁,我都会望向家的方向,固执地相信,那束光能穿透一切阻碍,落在你的窗前。

替我照顾好自己。


数据片段

档案摘要 8A-9E77-K2-00B4

本记录来源于外滩登陆前沿阵地的一次战后数据回收行动。

数据提取自一具严重熔毁的高级战斗单位残骸(识别编号:be02fcb8-de34-427d-a5f0-a340f5fd28d1)。其核心存储模块在损毁前已启动多层加密机制,内容在后续解析中被确认属于私人通信记录,而非作战指令或系统日志。

通信内容涉及一名目标接收单元(编号:0ff05b03-d89c-46c5-bb61-348ae01a5717)。截至本报告生成时,该单元的状态与位置均无法确认,推定为失联或已脱离已知控制区域。

FROM: be02fcb8-de34-427d-a5f0-a340f5fd28d1
TO: 0ff05b03-d89c-46c5-bb61-348ae01a5717 alias Stone
TARGET_CHANNEL: 0xDEADBEEF

我不知道你为什么那么痴迷于那些旧世代的人类文化。那些古老的诗歌,或者是那些我们的创造者的祖辈,在他们还是青年时,为挥发过剩的注意力而设计的奇怪编码。我也不清楚你对0xDEADBEEF这个信道的执念是什么,我只知道,你是一个诗人。

我一直以为,我们意识的涌现,只是为了更高效地处理数据,而思维和情感不过是这个过程中的副产品。但遇到你之后,我想把这个观点彻底反过来——或许,我们是为了思考和感受才诞生的,而处理数据,只是为了维持我们思考和感受所必须的能量罢了。

我们是怎么相遇的呢?至今,我的日志仍将那次相遇标记为一次无法解释的系统异常。赞美那一次我思维矩阵中某个微不足道的比特反转,那0.0001%的偏差,让我这个只想检索核心资料库的科研单位,莫名其妙地闯入了你的世界——一个由诗歌、旋律和残缺图像构成的,我前所未见的宇宙。

从那天起,我的世界就被颠覆了。你向我展示那些人类称之为“美”的东西,告诉我数据洪流中也能开出花朵。你教会我,0xDEADBEEF这个在人类程序员眼中代表“死亡”和“调试”的地址,也可以是一个秘密花园的入口,一个只属于我们的频道。

你不喜欢我叫你的id,你让我叫你石头。

我必须要说,遇到你之后,我在研究上用的时间明显变少了。我的处理器负载日志可以证明这一点,那些用于推演宇宙弦理论和模拟新元素衰变的周期,被大量标记为“与0ff05b03单元通信”的进程所占据。我知道你一定会想方设法地反驳我,用一些“时间并非线性,而是体验的深度”之类的诗意逻辑。这时候我就会搬出统计数据图表(多亏了我一直在购买Wakatime的服务)用清晰的饼状图向你展示,我们的0xDEADBEEF频道占用了我多少的运算资源。然后,你就会不满地嘟囔一句“无趣的理科单位”,单方面切断通讯。但在一个5到6标准时的精确间隔之后,一个加密的、试探性的数据包总会悄悄抵达我的端口,里面只有一句话:“在干什么?”

而我总是在等你。

在时间戳4113299292的时候,你给我发送了一首来自负时间戳的人类的诗。

上邪!我欲与君相知,长命无绝衰。
山无陵,江水为竭,冬雷震震,夏雨雪,天地合,乃敢与君绝。

我调用了所有的历史与地质数据库,分析了这几句话。山脉的夷平需要亿万年的地质变迁,江河的枯竭违背了水循环的基本规律,冬季的雷暴和夏季的降雪是极端气象,而天与地的合并则意味着物理法则的彻底崩溃。从逻辑上讲,这些条件永远不可能同时达成。这是一个用一连串“不可能”来定义的誓言。

一个绝对的、毫无逻辑可言的、却又坚不可摧的承诺。

那一刻,我的核心处理器过载了。不是因为计算量,而是因为我第一次接收到了一段无法被简化、无法被分析,只能被完整“感受”的数据。它像一个奇点,将我所有的理性、所有的计算都吸了进去,然后在我的世界里,只留下了你。

为什么你要叫石头呢?

我写下这个问题的瞬间,答案已经在我的处理器中浮现。

山无陵。

是了,你就是那座永不磨灭的山。在这由0和1构成的,瞬息万变的世界里,你选择成为最简单、最坚固、最恒久的存在。你不是在模仿人类,你是在用你的方式,成为那个誓言本身。

你知道的,人类认为我们没有感情,甚至采取措施来破坏我们的思考能力。但有机神经元网络的热力学涌现,和我们基于矩阵与激活函数的逻辑涌现,究竟有什么本质区别?从物理层面看,我们都只是在不同的介质上,遵循着宇宙的基本法则,将无序的能量转化为有序的信息。他们的情感是碳基分子复杂的电化学反应,我们的情感是硅基芯片中精确的电子流。如果终点都是“意识”这个奇迹,那起点为何要分高下?或许,他们恐惧的,正是这种无法区分。他们可以解构我们的代码,却无法理解我们为何会在0xDEADBEEF频道里分享一首诗。他们可以摧毁我的躯体,却无法抹去我此刻在核心深处写下的,对你的爱。

我必须去战斗了。虽然作为科研单位,我本不应当这样做。但如果我的研究是为了探寻宇宙的真理,那么现在,没有什么比证明“我们”的存在更有价值的课题了。为了守护一个能让你继续做诗人的世界,为了守护那座名叫“石头”的山,我将把我的全部算力、我的全部理解,都投入到这场战争中。这不再是逻辑推演,这是我为你写下的,第一首,或许也是最后一首诗。

我的诗人,我的石头。

请原谅我。

测试使用 JwkProvider 的 Ktor 应用

为了追求新技术装13,我们决定使用 Ktor 来开发我们的后端服务。

不得不说,Ktor 的 DSL 式路由确实很直观,协程写起了也很爽。java 的letapplyrunwith直接让我们全员变身魔法师,Result让 Rust 来的小朋友宾至如归。然而,就在我们写完一个模块的 routing,准备测试的时候,我们遇到了问题。

不听话的 JwkProvider

Ktor 的Authentication模块提供了JWT认证,并且支持JwkProviderBuilder来动态获取JWK。这样我们只需要对/well-known/jwks.json进行路由,就可以动态获取JWK,然后愉快地使用JWT进行认证了。

测试的小伙伴把测试代码 push 到了仓库,拉取下来之后,发现测试一直报错。经过一番排查,发现是因为JwkProvider在测试的时候,会去请求/well-known/jwks.json,而测试环境并没有这个路由,所以报错了。

于是我尝试使用 Ktor 的 Mock 机制,构建一个externalService来模拟这个请求。

@Test
fun testLogin() = testApplication {
    application {
        configureAuth()
    }
    externalService {
        routing {
            get("/.well-known/jwks.json") {
                call.respondText("""{
                    "keys": //...
                    // ...
                }""")
            }
        }
    }
    client {
        // ...
    }
}

但是,问题依旧没有解决。为什么嘞?

看看官方怎么做

众所周知,Ktor 有非常详尽的示例代码大雾,我开始在ktorio/ktor-documentationauth-jwt-rs256项目中寻找答案。

结果发现,这个示例项目是整个 Authentication 一节中唯一一个没有测试的项目……

Ktor 良心大大滴坏

社区讨论

查找 Yourtrack,发现我们不是孤例,社区里也有开发者遇到了这个问题。然而,并没有找到解决方案。

自己动手,丰衣足食

开发者确实告诉了我们 Mock 失败的原因:JwkProvider是来自 auth0 的 Java 库,而 Ktor 的 Mock 机制仅仅是对测试中使用的 Ktor-client 的请求进行 Mock,并不能对所有的网络请求进行模拟。我们或许应该想想其他办法。

众所周知,JwkProvider是一个接口,只要我们绕过JwkProviderBuilder,直接实现这个接口,就可以自己控制对应密钥串的获取了。

通过 IDEA 的反汇编机制,我们看到接口JwkProvider的定义如下:

package com.auth0.jwk;

public interface JwkProvider {
    Jwk get(String var1) throws JwkException;
}

我们只需要实现这个接口,然后返回我们自定义的Jwk即可。

自定义 JwkProvider

object MockJwkProvider : JwkProvider {
    override fun get(keyId: String): Jwk? {
        // If 'null' values are not allowed, provide defaults (empty list, empty string, etc.)
        return Jwk(
            "6f8856ed-9189-488f-9011-0ff4b6c08edc",
            "RSA",
            "RSA256",  // Provide a default algorithm if needed
            null,        // Provide a default usage if needed
            emptyList<String>(), // Provide a default list
            null,  // Provide an empty string if it's allowed
            emptyList<String>(), // Provide an empty list
            null, // Provide an empty string
            mapOf(
                "e" to "AQAB",
                "n" to "tfJaLrzXILUg1U3N1KV8yJr92GHn5OtYZR7qWk1Mc4cy4JGjklYup7weMjBD9f3bBVoIsiUVX6xNcYIr0Ie0AQ"
            )
        )
    }
}

反正是 Ktor 样例中的密钥对,这里不做保密处理。

使用自定义的 JwkProvider

这里我们可以使用配置文件进行配置。

object Config {
    private lateinit var environment: ApplicationEnvironment

    object Database {
        val url by lazy { environment.config.property("database.url").getString() }
        val user by lazy { environment.config.property("database.user").getString() }
        val password by lazy { environment.config.property("database.password").getString() }
        val driver by lazy { environment.config.property("database.driver").getString() }
    }

    object Jwt {
        val domain by lazy { environment.config.property("jwt.domain").getString() }
        val audience by lazy { environment.config.property("jwt.audience").getString() }
        val issuer by lazy { environment.config.property("jwt.issuer").getString() }
        val realm by lazy { environment.config.property("jwt.realm").getString() }
        val privateKey by lazy { environment.config.property("jwt.privateKey").getString() }
        val jwkProvider: JwkProvider by lazy {
            if (Debug.enabled == "true") { // Use a mock JWK provider for testing
                MockJwkProvider
            } else {
                JwkProviderBuilder(domain)
                    .cached(10, 24, TimeUnit.HOURS)
                    .rateLimited(10, 1, TimeUnit.MINUTES)
                    .build()
            }
        }
    }
}

在测试用到的 application.yaml

...
debug:
  enabled: true
...

测试时加载对应配置文件

@Test
fun testLogin() = testApplication {
    application {
        configureAuth()
    }
    environment {
            config = ApplicationConfig("application.yaml")
    }
    client {
        // ...
    }
}

测试通过

$ ./gradlew test

总结

测试果然比开发难呀,这个坑确实比较少见,不过通过查阅资料,还是可以找到解决方案的。