从 panic 到 Result:用 Rust 重新整理一个 ping 项目的错误处理

本文是对 Improving error handling - panics vs. proper errors 的整理与翻译。

原文属于 fasterthanli.me 的 "Making our own ping" 系列第 10 篇,主题是:在继续解析原始网络包之前,先把项目里的错误处理方式从零散的 unwrap()expect()panic,逐步整理成更适合库代码的 Result、自定义错误类型和可读 backtrace。(fasterthanli.me)

内容结构概览

这篇文章主要围绕一个问题展开:Rust 项目里什么时候可以 panic,什么时候应该返回一个真正的错误?

整体脉络如下:

  1. 先讨论 panicResult 的边界:应用代码里 panic 有时可以接受,但库代码更应该把可恢复错误交给调用者处理。
  2. 解释为什么 catch_unwind 不是常规错误处理方案。
  3. 检查项目中已有的 expect()unwrap(),尤其是动态加载 Windows DLL 的 loadlibrary 模块。
  4. 引入 thiserror,用更少代码定义清晰的错误类型。
  5. 引入 failure,让错误携带 backtrace,看到错误真正产生的位置。
  6. 引入 color-backtrace,让 backtrace 更容易阅读。
  7. 解释为什么错误类型不能随便持有短生命周期的 &str,以及为什么很多错误信息应该拥有 String
  8. 讨论 failure::Error 下的 downcast 和模式匹配问题。
  9. 最后把错误类型拆得更精确:LibraryErrorProcError 分开。
  10. 顺手用 thiserror 简化项目顶层 Error,并把 netinfo 里的 expect() 替换成明确的错误返回。

一、为什么这篇突然不继续写 ping,而是先讲错误处理?

在前几篇里,我们已经写出了一个叫 ersatz 的实验性项目,用 Rust 去做类似 ping 的事情。项目已经能和底层网络、Windows API、raw socket 这些东西打交道,但代码里还混着几种不同的错误处理风格:有些函数返回 Result<T, E>,有些地方直接 unwrap(),有些地方用 expect(),还有一个手写的顶层 Error 枚举,里面包着 raw socket 错误、I/O 错误和 Win32 错误。(fasterthanli.me)

这种状态在探索阶段很常见。刚开始写一个底层项目时,注意力往往在"先让它跑起来",不是在"把每一种失败都设计得很优雅"。但当代码逐渐接近一个可复用的库时,错误处理就不能再随便了。

因为应用程序崩了,最多是当前命令失败;库代码崩了,可能会把使用它的整个程序一起带走。

原文这一篇的核心转折点就在这里:ersatz 现在看起来像命令行程序,但之后可能会被另一个程序 sup 当成库来使用,用来发送和接收 ICMP 包。也就是说,它不再只是"自己跑起来就行",还要变成别人能安全调用的组件。(fasterthanli.me)


二、panic 到底什么时候可以接受?

Rust 里的 panic! 不一定是坏东西。它表达的是一种"程序已经进入不该发生的状态"的情况。比如:

rust 复制代码
let value = maybe_value.expect("value should exist");

这段代码的意思不是"我不知道这里会不会失败",而是"按照我的程序逻辑,这里必须有值;如果没有,说明我的假设错了"。

原文给出的判断标准大致是:

应用代码里 panic 可以接受。库代码里 panic 也不是绝对禁止,但它应该只用于两类情况:第一,库本身有 bug;第二,调用者以某种类型系统无法提前阻止的方式误用了库。除此之外,如果错误是调用者有可能处理的,就应该返回 Result。(fasterthanli.me)

举个例子,项目里有一段代码会查找默认网络接口:

rust 复制代码
let route = routes
    .iter()
    .find(|r| r.dest == Ipv4Addr::new(0, 0, 0, 0))
    .expect("default route should exist");

这在命令行程序里也许还说得过去。机器如果连默认路由都没有,程序确实很难继续工作。但如果 ersatz 被某个长期运行的服务器使用呢?服务器的某个请求处理逻辑调用了这个库,结果库内部 panic,把整个服务进程干掉,这就不太合理了。

更稳妥的做法是:返回一个明确的错误,比如 DefaultRouteMissing,让调用方决定是重试、降级、返回 HTTP 500,还是记录日志后继续服务其他请求。


三、能不能用 catch_unwind 把 panic 接住?

Rust 提供了 std::panic::catch_unwind(),看起来好像可以把 panic 当异常一样捕获:

rust 复制代码
let result = std::panic::catch_unwind(|| {
    panic!("something went wrong");
});

但原文马上指出,这不是一个理想的常规错误处理方式。

第一,即使你捕获了 panic,panic 的信息仍然可能被打印出来。也就是说,程序没有终止,但用户还是看到了"线程 panic 了"的输出,这会让日志非常混乱。

第二,catch_unwind() 返回的错误不是一个清晰的业务错误类型,而是类似 Box<dyn Any + Send> 这样的东西。要拿到里面真正的内容,你需要 downcast,而且 downcast 还可能失败。

第三,catch_unwind() 并不能捕获所有 panic。Rust 的 panic 策略可以是 unwind,也可以是 abort;如果配置成 abort,进程会直接终止,根本没有 unwind 过程可捕获。原文也引用了标准库文档对这一点的说明。(fasterthanli.me)

所以,catch_unwind() 更像是跨越 FFI 边界、线程边界、插件边界时的保护措施,而不是日常业务逻辑里的错误处理机制。

真正应该做的是:把可预期、可表达、可恢复的问题建模成 Result<T, E>


四、先从 loadlibrary 里的 expect 开始

项目里有一个 loadlibrary 模块,用来动态加载 Windows DLL,并获取某个函数指针。早期代码里,它大概是这种风格:

rust 复制代码
pub fn open(name: &str) -> Option<Library> {
    let c_name = CString::new(name).expect("invalid library name");
    // 调用 LoadLibraryA
}

这里有一个隐藏问题:CString::new(name) 是可能失败的。因为 C 字符串不能在中间包含 \0,如果传入的字符串里有 NUL 字节,构造 CString 就会失败。

如果这个模块只在程序内部用,并且传入的永远是硬编码字符串,比如 "KERNEL32.dll",那 expect() 基本不会出问题。即使出问题,也很可能在测试阶段暴露出来。

但原文提出了一个更现实的场景:假设未来有一个 REPL,允许用户输入 DLL 名称,然后动态加载。如果用户输入了非法字符串,我们显然不希望整个 REPL 直接 panic 退出。(fasterthanli.me)

这说明 Option<T> 已经不够用了。因为 Option 只能告诉你"有"或"没有",却不能告诉你为什么失败。这里至少有几种失败原因:

  • 传入的库名不是合法的 C 字符串;
  • DLL 找不到;
  • DLL 找到了,但指定函数不存在;
  • 函数名本身也可能不是合法的 C 字符串。

这些都应该进入错误类型。


五、用 thiserror 少写很多模板代码

如果手写 Rust 错误类型,通常要写 DebugDisplaystd::error::Error,还可能要写一堆 From 实现。对于探索性项目来说,这很容易让人嫌麻烦,于是继续 unwrap()

thiserror 的价值就在这里:它让我们用派生宏快速定义清晰的错误类型。原文在这里引入了 thiserror,用它替代大量手写错误实现。(fasterthanli.me)

示意代码可以写成这样:

rust 复制代码
use thiserror::Error;

#[derive(Debug, Error)]
pub enum LoadLibraryError {
    #[error("invalid library name: {0}")]
    InvalidLibraryName(String),

    #[error("library not found: {0}")]
    LibraryNotFound(String),

    #[error("symbol not found: {symbol} in {library}")]
    SymbolNotFound {
        library: String,
        symbol: String,
    },
}

这样做以后,Library::new() 就可以从:

rust 复制代码
fn new(name: &str) -> Option<Library>

变成:

rust 复制代码
fn new(name: &str) -> Result<Library, LoadLibraryError>

这是一种很重要的设计变化。

以前调用者只能知道"加载失败了"。现在调用者可以知道"传入名称非法""库不存在"还是"函数不存在"。错误不再只是程序崩溃前的一句提示,而变成了程序接口的一部分。


六、Result 不是为了让你最后继续 unwrap

不过,单纯把函数签名改成 Result 并不意味着错误处理就变好了。如果调用方还是这么写:

rust 复制代码
let lib = Library::new(name).unwrap();

那失败时仍然会 panic。

区别在于:这次 panic 的原因更清楚了。以前可能只是:

text 复制代码
called `Option::unwrap()` on a `None` value

现在至少能看到具体错误,比如"非法库名"或者"找不到某个 proc"。

但原文指出,这还不够。因为此时 backtrace 主要告诉你 .unwrap() 发生在哪里,而不是错误最初是在哪里构造出来的。对于复杂代码来说,这两者不总是同一个位置。(fasterthanli.me)

这就引出了下一步:不仅要有错误类型,还要有错误发生位置的上下文。


七、failure:让错误携带 backtrace

原文接着引入了 failure。在当时的 Rust 生态里,failure 是一个常见的错误处理库,它提供了 failure::Errorfailure::Fallible<T>

failure::Fallible<T> 可以理解成:

rust 复制代码
type Fallible<T> = Result<T, failure::Error>;

它的一个重要能力是:错误可以携带 backtrace。这样,当错误一路通过 ? 往上传递时,我们不只是看到最后在哪里处理失败,也能看到错误更早是在什么调用栈里产生的。原文通过给 failure 开启 backtrace 功能,展示了错误发生位置的栈信息。(fasterthanli.me)

在库函数里,代码大概会变成这种风格:

rust 复制代码
fn open_library(name: &str) -> failure::Fallible<Library> {
    let c_name = CString::new(name)
        .map_err(|_| LoadLibraryError::InvalidLibraryName(name.to_owned()))?;

    let handle = unsafe { load_library(c_name.as_ptr()) };

    handle.ok_or_else(|| LoadLibraryError::LibraryNotFound(name.to_owned()))?;

    Ok(Library { /* ... */ })
}

这里的关键不是具体 API,而是 ? 的传播方式:底层错误可以被转换成统一的错误容器,然后由上层统一打印、记录或处理。

不过,原文也指出了一个新问题:backtrace 变多了,而且输出很啰嗦。你可能同时看到 .unwrap() 的 panic backtrace,以及 failure 捕获的错误 backtrace。信息是多了,但不一定更易读。(fasterthanli.me)


八、better-panic 只能美化 panic,不能美化普通错误

在前面,作者试过 better-panic。它可以把 panic 的 backtrace 打印得更好看,带颜色,也更容易定位源码位置。

但它有一个天然限制:它只处理 panic。

当我们开始认真返回 Result 时,很多失败已经不再是 panic 了。错误通过 Result 返回,经过 ? 一路上传,最后由顶层处理。这种情况下,better-panic 就帮不上忙。原文也因此把它移除了。(fasterthanli.me)

这其实是一个很好的提醒:

panic 的调试体验和 Result 的错误报告体验,是两套东西。不能因为 panic 的 backtrace 漂亮,就把本该返回错误的地方写成 panic。


九、color-backtrace:让错误栈更适合人看

为了解决 backtrace 太难读的问题,原文又试了 color-backtrace。它可以把栈信息打印得更清晰,并隐藏一些初始化阶段、panic 之后的无关帧。原文提到,它当时也提供了对 failure 的实验性支持。(fasterthanli.me)

不过它不是"装上就自动解决一切"。对于 failure 的错误,作者需要在顶层手动打印 backtrace。

于是程序结构被改成两层:

rust 复制代码
fn main() {
    if let Err(err) = real_main() {
        eprintln!("Fatal error: {}", err);
        // 这里统一打印 backtrace
    }
}

fn real_main() -> failure::Fallible<()> {
    // 真正的程序逻辑
    Ok(())
}

这个结构很常见,也很实用。

main() 负责最后兜底:如果程序失败,就以统一格式报告错误。real_main() 负责业务逻辑:里面可以自由使用 ?,让错误自然向上传播。

这样做之后,命令行程序的错误输出就更像一个产品,而不是一堆随缘的 panic 日志。


十、错误信息为什么不能随便借用 &str?

接下来,原文讨论了一个非常 Rust 的问题:生命周期。

一开始,为了让错误类型能保存传入的库名,作者把函数签名从:

rust 复制代码
fn new(name: &str) -> Result<Library, Error>

改成了类似:

rust 复制代码
fn new(name: &'static str) -> Result<Library, Error>

这看起来解决了问题,因为错误里可以安全保存 name。但它也带来了限制:调用者只能传入 'static 字符串,比如字符串字面量。对于 REPL 这种场景,用户输入是运行时生成的 String,它不是 'static

为什么不能直接把普通 &str 存进错误里?

因为错误往往会在函数返回之后才被格式化、打印或记录。函数一返回,局部变量可能已经被释放。如果错误里只是借用了一个局部字符串,那它可能指向已经无效的内存。Rust 编译器会阻止这种情况。原文用几个小例子解释了这一点:局部 String 的引用不能逃出函数,但字符串字面量可以,因为它拥有 'static 生命周期;另一种方案是让错误类型拥有自己的 String。(fasterthanli.me)

因此,更合理的错误类型应该拥有数据:

rust 复制代码
#[derive(Debug, Error)]
pub enum LibraryError {
    #[error("invalid library name")]
    InvalidName,

    #[error("library not found: {0}")]
    NotFound(String),
}

如果错误需要在未来某个时间点被打印,它就应该拥有足够的信息,而不是借用早就可能消失的局部变量。


十一、让 Library 自己记住名字

为了让错误消息更清楚,原文还让 Library 结构体保存了自己的名称。

为什么这有用?

假设我们成功加载了某个 DLL,之后再从这个 DLL 里找函数。如果函数不存在,错误消息最好能告诉我们:

  • 找的是哪个函数;
  • 在哪个库里找;
  • 是函数名非法,还是函数不存在。

所以 Library 不应该只保存底层 handle,也应该保存库名。

示意结构如下:

rust 复制代码
pub struct Library {
    name: String,
    handle: RawHandle,
}

然后函数查找失败时,就可以返回更具体的错误:

rust 复制代码
#[derive(Debug, Error)]
pub enum ProcError {
    #[error("invalid proc name")]
    InvalidName,

    #[error("proc {proc} not found in {library}")]
    NotFound {
        library: String,
        proc: String,
    },
}

这样错误信息不需要调用者猜,也不需要强迫调用者去读 backtrace。仅凭错误文本,就能知道大概发生了什么。


十二、一个微妙的问题:? 可能转换成你不想要的错误类型

原文中有一个很细的点,值得单独拿出来讲。

CString::new(name)? 失败时,它产生的是 std::ffi::NulError。而在使用 failure::Fallible<T> 时,? 可能直接把 NulError 转进 failure::Error,而不是先转成我们自定义的 Error::InvalidName

结果就是:我们精心写好的"invalid library name"这类错误消息可能被绕过去了。

解决办法是显式指定转换:

rust 复制代码
let c_name = CString::new(name)
    .map_err(MyError::from)?;

这句话的意思是:不要让 ? 自己猜该怎么转,先把 NulError 转成我的错误类型,再继续向上传播。

这个细节说明,Rust 的 ? 很方便,但它不是魔法。尤其当你引入统一错误容器时,要注意错误转换路径是否符合预期。原文也正是在这里发现了错误消息被"吞掉"的问题,然后用 map_err(Error::from) 修正。(fasterthanli.me)


十三、failure::Error 可以 downcast,但库里不一定应该返回它

如果所有函数都返回 failure::Error,调用者当然可以统一处理错误。但问题是:调用者如果想针对某一种具体错误做分支,就要 downcast。

比如调用者想知道失败是不是"库不存在",就不能直接 match,而要类似这样:

rust 复制代码
if let Some(err) = err.downcast_ref::<LibraryError>() {
    match err {
        LibraryError::NotFound(name) => {
            // 换一个库试试
        }
        _ => {}
    }
}

这能用,但不够自然。

原文由此提出一个更好的边界划分:库代码应该返回自己的具体错误类型;应用代码可以在顶层使用 failure::Error 这类统一错误容器。(fasterthanli.me)

也就是说:

rust 复制代码
fn new(name: &str) -> Result<Library, LibraryError>

比下面这样更适合库 API:

rust 复制代码
fn new(name: &str) -> failure::Fallible<Library>

前者对调用者更友好,因为调用者可以直接 match LibraryError。后者对应用顶层更方便,因为所有错误都能统一打印 backtrace。

这就是错误处理设计里非常重要的一点:库和应用的需求不一样。


十四、把 LibraryError 和 ProcError 分开

原文接着进一步细化错误类型。

一开始,loadlibrary 模块可能只有一个总的 Error

rust 复制代码
enum Error {
    InvalidName,
    LibraryNotFound,
    ProcNotFound,
}

但这其实不够精确。因为 Library::new() 不可能返回 ProcNotFound,而 get_proc() 不可能返回 LibraryNotFound。如果两个函数都返回同一个大而全的错误类型,调用者在匹配时就会遇到一些理论上不可能出现的分支。

更好的方式是拆开:

rust 复制代码
pub enum LibraryError {
    InvalidName,
    NotFound(String),
}

pub enum ProcError {
    InvalidName,
    NotFound {
        library: String,
        proc: String,
    },
}

于是函数签名变成:

rust 复制代码
fn new(name: &str) -> Result<Library, LibraryError>;

fn get_proc<T>(&self, name: &str) -> Result<T, ProcError>;

这个设计有几个好处:

第一,错误类型更贴近函数本身。

第二,调用者匹配错误时,不需要处理不可能出现的情况。

第三,错误变体名称可以更短。比如 ProcError::NotFound 就足够清楚,不需要叫 ProcNotFound,因为上下文已经在类型名里了。

第四,库代码里的 ? 也更自然,不需要到处做额外转换。

这就是 Rust 错误类型设计里一个很实用的原则:不要急着做一个"万能错误类型"。在库的边界上,具体比统一更重要。


十五、应用顶层仍然可以统一处理错误

虽然库函数返回具体错误类型,但应用的顶层仍然可以使用统一错误容器。

例如主流程里可能是:

rust 复制代码
fn real_main() -> failure::Fallible<()> {
    let kernel32 = Library::new("KERNEL32.dll")?;
    let func = kernel32.get_proc::<SomeFn>("SomeFunction")?;

    // 继续执行程序逻辑

    Ok(())
}

这里 LibraryErrorProcError 会通过 ? 转成顶层统一错误。调用方不需要在主流程里手动写一堆 match,除非它确实想针对某个错误恢复。

原文总结这个状态是"几个世界里最好的组合":应用层可以得到干净的 backtrace;库层保留完整类型信息;? 在库代码里也能自然工作。(fasterthanli.me)

这也是整篇文章最重要的实践结论之一:

库 API 返回具体错误;应用入口统一兜底报告。


十六、bind! 宏里的 unwrap 还要不要改?

项目里还有一个 bind! 宏,用来声明某个 DLL 里的函数,然后通过懒加载拿到函数指针。里面也有 unwrap()

这时候作者没有机械地把所有 unwrap() 都消灭掉,而是判断:这里的 unwrap() 可以保留。

原因是:

第一,bind! 绑定的是静态库名和静态函数名,不是用户输入。

第二,这些函数是懒加载的,真正出错时可能已经在调用某个外部函数的过程中,很难把错误优雅地传出去。

第三,这些函数的 ABI 可能根本不允许返回 Rust 风格的错误。

所以这里 panic 是可以接受的。但前面做的错误类型改造仍然有价值:如果某个函数名写错了,现在 panic 信息里会明确告诉你哪个库里缺了哪个函数,而不是只说 Option::unwrap() 遇到了 None。原文也展示了这种改造后的错误输出更容易定位。(fasterthanli.me)

这点很重要:好的错误处理不是"绝不 panic",而是知道哪里应该 panic,哪里不应该 panic。


十七、用 thiserror 简化项目顶层 Error

除了 loadlibrary 模块,项目本来还有一个顶层错误类型,大概负责包装:

  • raw socket 错误;
  • I/O 错误;
  • Win32 错误码。

早期写法需要手动实现 FromDisplayDebugstd::error::Error。这类代码很机械,也很容易写得又长又无聊。

thiserror 后,可以直接写成:

rust 复制代码
#[derive(Debug, Error)]
pub enum Error {
    #[error("raw socket error: {0}")]
    RawSocket(#[from] rawsock::Error),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Win32 error code {0} (0x{0:x})")]
    Win32(u32),
}

#[from] 的意思是:当底层出现 rawsock::Errorstd::io::Error 时,可以自动转换成这个顶层 Error。这样之前依赖 ? 的地方不用改,调用点仍然干净。原文也明确说,这次替换后,代码库里使用 Error 的地方基本不需要变化。(fasterthanli.me)

这正是 thiserror 最舒服的地方:它不强迫你改变错误模型,只是帮你减少模板代码。


十八、把 netinfo 里的 expect 也换掉

最后,原文回到最开始那个网络接口问题。

netinfo::default_nic_guid() 里有几个 expect()

  • 找不到默认路由时 panic;
  • 找不到默认网络接口时 panic;
  • 解析接口名称里的 GUID 失败时 panic。

这些其实都可以变成明确错误。

顶层错误类型里新增几个变体:

rust 复制代码
#[derive(Debug, Error)]
pub enum Error {
    // 其他错误略

    #[error("default IP route not found")]
    DefaultRouteMissing,

    #[error("default network interface not found")]
    DefaultInterfaceMissing,

    #[error("default network interface could not be identified")]
    DefaultInterfaceUnidentified,
}

然后把原来的:

rust 复制代码
let route = routes.iter()
    .find(is_default_route)
    .expect("default route should exist");

改成:

rust 复制代码
let route = routes.iter()
    .find(is_default_route)
    .ok_or(Error::DefaultRouteMissing)?;

这就是从"我觉得这里应该有,没有就崩"变成"这里可能没有,没有就返回一个清晰错误"。

同样,接口不存在、GUID 找不到,也都可以用 ok_or(...) ? 表达。

这种改动看似很小,但它改变了库的行为:以前调用方没有选择,只能接受进程 panic;现在调用方可以拿到错误,决定下一步怎么做。原文也在这一段完成了当前代码库中已知 panic 点的清理。(fasterthanli.me)


十九、这篇真正想教的不是某个 crate,而是错误处理边界

这篇文章表面上讲了几个 crate:

  • thiserror:帮你定义自己的错误类型,少写模板代码;
  • failure:让错误可以携带 backtrace;
  • better-panic:让 panic 的 backtrace 更好看;
  • color-backtrace:让 backtrace 输出更适合人类阅读。

原文最后也总结了这些 crate 的作用。(fasterthanli.me)

但真正重要的不是"该不该用这些 crate",而是下面这些判断:

第一,panic 不是错误处理的默认方式。它更适合表达程序 bug、违反不变量、类型系统无法表达的误用。

第二,库代码应该尽量返回具体错误。因为调用者可能有恢复策略。

第三,应用顶层可以统一处理错误。比如统一打印"Fatal error"以及 backtrace。

第四,错误信息要包含足够上下文。只说"None unwrap 失败"没有意义;说"在 KERNEL32.dll 里找不到某个函数"才有调试价值。

第五,错误类型应该拥有需要长期保存的数据。不要为了省一次 String 分配,就把短生命周期引用塞进错误里。

第六,不要为了"消灭 unwrap"而机械重构。像 bind! 这种绑定静态系统 API 的地方,panic 可能是合理的。关键是 panic 信息要足够明确。


二十、从这个 ping 项目里能学到什么?

这篇虽然暂时没有继续解析 ICMP 或 IPv4 包,但它补上了一个底层项目迟早要面对的问题:错误处理是 API 设计的一部分。

尤其是写 Rust 库时,错误处理不只是"出了错打印一下"。它关系到:

  • 调用者能不能区分不同失败原因;
  • 调用者能不能恢复;
  • 日志里能不能看到真正出错位置;
  • 项目从实验代码走向库代码时,边界是否清晰;
  • 未来调试底层网络问题时,能不能少猜一点。

对于这个自制 ping 项目来说,后面要处理原始数据包、IPv4 头、ICMP 报文、系统 API、socket 权限等各种低层细节。任何一步都可能失败。如果错误处理还停留在 unwrap()expect(),后面会非常痛苦。

所以这篇先停下来整理错误处理,其实很合理。

先把失败路径设计清楚,后面继续解析网络包时,代码才不会被一堆不明所以的 panic 打断。


结语

这一篇的主题可以概括成一句话:

不要把所有失败都当成程序崩溃;能表达、能传播、能被调用者处理的失败,就应该成为类型系统里的一部分。

在 Rust 里,这通常意味着:用 Result 表达失败,用自定义错误类型表达原因,用 ? 保持调用链干净,在应用顶层统一报告错误,并在必要时附带 backtrace。

等这些基础设施准备好以后,项目才更适合继续往下走:真正开始解析 IPv4 包,继续完成"自己写一个 ping"的目标。

相关推荐
蝎子莱莱爱打怪1 小时前
XZLL-IM干货系列 03|消息 ID 设计:一个 UUID 搞不定的事,我用两个 ID 解决了
后端·面试·开源
森蓝情丶2 小时前
我给 AI 搭了个法庭:一个前端仔的 LangGraph 实战全记录
前端·后端
JensCS猿2 小时前
从 Spring Boot 回看 SSM 框架:手动挡与自动挡的驾驶哲学
后端
爱勇宝2 小时前
干了近 8 年,一夜之间被裁:AI 时代,程序员最该害怕的不是 AI
前端·后端·程序员
科米米2 小时前
嵌入式日志模块
后端
血小溅3 小时前
三大 AI 编码框架深度对比:GSD vs OpenSpec vs Superpowers
人工智能·后端
ThanksGive3 小时前
层级时间轮看门狗
后端
GetcharZp3 小时前
告别繁琐命令行!这款容器可视化神器,让 Docker/K8s 管理变得如此简单
后端
铁皮饭盒7 小时前
bun直接tsx,优雅!
javascript·后端