编程 Rust + pgrx:从零构建生产级PostgreSQL扩展的完整实战指南

2026-05-17 04:43:31 +0800 CST views 5

Rust + pgrx:从零构建生产级PostgreSQL扩展的完整实战指南

当PostgreSQL的扩展生态遇上Rust的内存安全与零成本抽象,数据库的边界被彻底重新定义。本文将深入pgrx框架底层,从FDW(外部数据包装器)到自定义聚合函数,从内存管理到异步运行时集成,带你掌握用Rust扩展PostgreSQL的全部实战技巧。

引言:为什么要用Rust写PostgreSQL扩展?

PostgreSQL的扩展机制(Extension)是其最强大的特性之一。从内置的contrib模块到第三方扩展(如PostGIS、Citus、TimescaleDB),PL/pgSQL和C语言长期占据扩展开发的主流。

但C扩展的痛点显而易见:

  • 内存安全隐患:UAF、双释放、缓冲区溢出在扩展中屡见不鲜
  • 并发安全挑战:错误使用MemoryContext会导致难以调试的跨内存上下文访问
  • 开发效率低:手动管理PG_FUNCTION_ARGS、错误处理宏、palloc/pfree配对

Rust通过pgrx(PostgreSQL Rust eXtension framework)完美解决了这些问题。pgrx提供:

  1. 零成本FFI抽象:Rust类型系统映射到PostgreSQL内部类型
  2. 内存安全保证:编译期防止palloc泄漏和悬垂指针
  3. 自动化SQL生成:通过过程宏自动生成CREATE FUNCTION语句
  4. 完整测试框架:支持pg_test集成测试,无需手动搭建测试数据库

本文将基于pgrx 0.12 + PostgreSQL 16/17/18,从实战角度深入讲解。


第一部分:pgrx架构深度解析

1.1 pgrx的核心设计哲学

pgrx并不是一个"Rust绑定生成器"(如bindgen),而是一个完整的ORM式框架,它在Rust类型和PostgreSQL内部机制之间建立了双向映射:

┌─────────────────────────────────────────────────────┐
│              PostgreSQL Backend Process              │
│  ┌─────────────────────────────────────────────┐   │
│  │  pgrx C Shim Layer (pgrx.c)              │   │
│  │  - 拦截 PG function calls                  │   │
│  │  - 转换 MemoryContext                     │   │
│  │  - 异常处理 (PG_TRY/PG_CATCH)           │   │
│  └──────────────┬────────────────────────────┘   │
│                 │ FFI Boundary                     │
│  ┌──────────────▼────────────────────────────┐   │
│  │         Rust Extension Code               │   │
│  │  - #[pg_extern] functions                │   │
│  │  - Safe wrappers around PG internals     │   │
│  │  - Custom types implementing PgType      │   │
│  └───────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

关键设计决策

  • 编译模型:pgrx将Rust代码编译为.so(Linux)或.dylib(macOS),PG通过LOAD命令动态链接
  • 内存隔离:Rust的堆分配(通过std::alloc)和PG的palloc完全隔离,pgrx提供安全的跨边界传输
  • Panic处理:Rust的panic!被转换为PG的elog(ERROR),保证事务回滚安全

1.2 pgrx的项目结构

初始化一个pgrx项目:

# 安装 pgrx CLI
cargo install cargo-pgrx

# 初始化 PostgreSQL(首次使用)
pgrx init --pg16 download

# 创建扩展项目
cargo pgrx new pg_my_extension
cd pg_my_extension

生成的项目结构:

pg_my_extension/
├── Cargo.toml          # 依赖 pgrx = "0.12"
├── pg_my_extension.control  # PG扩展控制文件
├── sql/
│   └── pg_my_extension--0.0.0.sql  # 自动生成的SQL
└── src/
    └── lib.rs          # 扩展入口

Cargo.toml的关键配置:

[lib]
crate-type = ["cdylib"]  # 编译为C动态库

[features]
default = ["pg16"]  # 默认瞄准PG 16
pg16 = ["pgrx/pg16"]
pg17 = ["pgrx/pg17"]
pg18 = ["pgrx/pg18"]  # PG 18支持(2026年最新)

[dependencies]
pgrx = "0.12.0"

第二部分:基础扩展开发实战

2.1 第一个pgrx函数:类型映射全解析

让我们从一个简单的字符串处理函数开始:

// src/lib.rs
use pgrx::prelude::*;

pgrx::pg_module_magic!();  // PG扩展必须的magic block

#[pg_extern]
fn hello_pgrx(name: &str) -> String {
    format!("Hello, {}! Welcome to Rust + PostgreSQL.", name)
}

编译与安装

# 编译扩展
cargo pgrx run pg16 --release

# 在PG命令行中
CREATE EXTENSION pg_my_extension;
SELECT hello_pgrx('程序员茄子');
-- 输出: Hello, 程序员茄子! Welcome to Rust + PostgreSQL.

类型映射表(常用)

PostgreSQL类型Rust类型(pgrx)备注
TEXT&str / String自动UTF-8验证
INT4i32
INT8i64
FLOAT8f64
BOOLbool
BYTEAVec<u8>
JSONBpgrx::JsonB支持serde序列化
NULLOption<T>
SETOFimpl Iterator<Item=T>返回多行

2.2 内存管理:pgrx如何保证安全

PostgreSQL有自己的内存管理子系统(palloc/pfree/MemoryContext),而Rust使用系统的malloc/free。pgrx在两者间建立了安全的桥梁:

错误示例(C扩展常见问题)

// C扩展中常见的内存泄漏
PG_FUNCTION_INFO_V1(bad_func);
Datum bad_func(PG_FUNCTION_ARGS) {
    char *ptr = palloc(1024);  // 从CurrentMemoryContext分配
    // 忘记 pfree(ptr) !!!
    PG_RETURN_VOID();
}

pgrx的安全抽象

#[pg_extern]
fn safe_func() -> String {
    let data = vec![1, 2, 3];  // Rust堆分配
    // 离开作用域时自动drop,无泄漏风险
    
    // 需要传递给PG时,pgrx自动复制到PG的MemoryContext
    format!("data: {:?}", data)
}

PgMemoryContexts:pgrx暴露了PG的内存上下文API,但用Rust RAII包装:

use pgrx::memcxt::PgMemoryContexts;

#[pg_extern]
fn allocate_in_spi_context() -> i32 {
    // 在SPI内存上下文中分配,SPI结束时自动清理
    let ctx = PgMemoryContexts::SPI;
    let _buf = ctx.palloc::<u8>(1024);
    
    42
}

2.3 错误处理:从Rust的Result到PG的ERROR

pgrx将Rust的Result<T, E>无缝转换为PostgreSQL的错误机制:

#[pg_extern]
fn parse_config(json_str: &str) -> Result<(), pgrx::Error> {
    let config: serde_json::Value = serde_json::from_str(json_str)
        .map_err(|e| pgrx::Error::new(format!("JSON解析失败: {}", e)))?;
    
    if config["version"].as_i64().unwrap_or(0) < 2 {
        return Err(pgrx::Error::new("配置版本过低,需要 >= 2"));
    }
    
    Ok(())
}

在PG中调用:

SELECT parse_config('{"version": 1}');
-- ERROR: 配置版本过低,需要 >= 2
-- 事务自动回滚,连接保持

第三部分:高级扩展开发

3.1 自定义类型(Custom Types)

pgrx允许你定义完全自定义的PostgreSQL类型,包括:

  1. 复合类型(Composite Types)
  2. 枚举类型(Enums)
  3. 范围类型(Range Types)
  4. 自定义标量类型

示例:定义一个Rust风格的Result类型

use pgrx::prelude::*;
use serde::{Serialize, Deserialize};

// 定义PostgreSQL枚举
#[derive(PostgresEnum, Serialize, Deserialize)]
enum Status {
    Ok,
    Err,
}

// 定义复合类型
#[derive(PostgresType, Serialize, Deserialize)]
struct ApiResponse {
    status: Status,
    data: String,
    error_msg: Option<String>,
}

#[pg_extern]
fn make_response(success: bool, data: &str) -> ApiResponse {
    if success {
        ApiResponse {
            status: Status::Ok,
            data: data.to_string(),
            error_msg: None,
        }
    } else {
        ApiResponse {
            status: Status::Err,
            data: String::new(),
            error_msg: Some("操作失败".to_string()),
        }
    }
}

在SQL中使用:

SELECT (make_response(true, 'hello')).status;
-- 输出: Ok

CREATE TABLE api_logs (
    id SERIAL PRIMARY KEY,
    response ApiResponse
);

INSERT INTO api_logs (response) VALUES (make_response(true, 'data'));

3.2 外部数据包装器(FDW)

FDW是PostgreSQL最强大的扩展类型之一,它允许PG查询外部数据源(如MySQL、Redis、REST API)就像查询本地表一样。

pgrx实现FDW的核心trait

use pgrx::fdw::*;

struct MyApiFdw {
    api_url: String,
    cache: Vec<MyRow>,
}

impl ForeignDataWrapper for MyApiFdw {
    fn begin_scan(&mut self, _quals: Vec<Qual>, _columns: Vec<Column>) -> PgResult<()> {
        // 从外部API获取数据并缓存到self.cache
        self.cache = fetch_from_api(&self.api_url)?;
        Ok(())
    }
    
    fn iter_scan(&mut self) -> PgResult<Option<TupleTableSlot>> {
        // 返回下一行
        if let Some(row) = self.cache.pop() {
            let mut slot = TupleTableSlot::new();
            slot.push(row.field1);
            slot.push(row.field2);
            Ok(Some(slot))
        } else {
            Ok(None)
        }
    }
    
    fn end_scan(&mut self) -> PgResult<()> {
        self.cache.clear();
        Ok(())
    }
}

// 注册FDW
pgrx::pg_magic! {
    name = "my_api_fdw",
    version = "0.1.0",
    fdw = MyApiFdw,
}

实战案例:Redis FDW

// 完整的Redis FDW实现(简化版)
use redis::{Client, Commands};

struct RedisFdw {
    client: Client,
    current_key: String,
    keys: Vec<String>,
    key_idx: usize,
}

impl ForeignDataWrapper for RedisFdw {
    fn new(options: Vec<(String, String)>) -> PgResult<Self> {
        let redis_url = options.iter()
            .find(|(k, _)| k == "url")
            .map(|(_, v)| v.clone())
            .unwrap_or("redis://localhost:6379".to_string());
        
        let client = Client::open(redis_url)
            .map_err(|e| pgrx::Error::new(e.to_string()))?;
        
        Ok(RedisFdw {
            client,
            current_key: String::new(),
            keys: Vec::new(),
            key_idx: 0,
        })
    }
    
    fn begin_scan(&mut self, quals: Vec<Qual>, _columns: Vec<Column>) -> PgResult<()> {
        // 从WHERE子句中提取key模式
        let pattern = quals.iter()
            .find(|q| q.field == "key")
            .and_then(|q| {
                if let QualValue::String(s) = &q.value {
                    Some(s.clone())
                } else {
                    None
                }
            })
            .unwrap_or("*".to_string());
        
        let mut conn = self.client.get_connection()
            .map_err(|e| pgrx::Error::new(e.to_string()))?;
        
        self.keys = conn.keys::<_, String>(pattern)
            .map_err(|e| pgrx::Error::new(e.to_string()))?;
        
        self.key_idx = 0;
        Ok(())
    }
    
    fn iter_scan(&mut self) -> PgResult<Option<TupleTableSlot>> {
        if self.key_idx >= self.keys.len() {
            return Ok(None);
        }
        
        let key = &self.keys[self.key_idx];
        let mut conn = self.client.get_connection()
            .map_err(|e| pgrx::Error::new(e.to_string()))?;
        
        let value: String = conn.get(key)
            .unwrap_or_default();
        
        let mut slot = TupleTableSlot::new();
        slot.push(key.clone());
        slot.push(value);
        
        self.key_idx += 1;
        Ok(Some(slot))
    }
}

使用方式:

CREATE EXTENSION redis_fdw;

CREATE SERVER redis_server FOREIGN DATA WRAPPER redis_fdw
    OPTIONS (url 'redis://localhost:6379');

CREATE FOREIGN TABLE redis_kv (
    key TEXT,
    value TEXT
) SERVER redis_server;

-- 现在可以像查询普通表一样查询Redis!
SELECT * FROM redis_kv WHERE key = 'user:1001';

3.3 自定义聚合函数(Aggregate Functions)

PostgreSQL的聚合函数比普通函数复杂,需要实现状态转移函数(SFUNC)最终结果函数(FINALFUNC)

pgrx通过#[pg_aggregate]宏简化了这个流程:

use pgrx::aggregate::*;

// 定义一个计算"移动平均"的聚合
#[pg_aggregate]
impl Aggregate for MovingAvg {
    type Args = f64;  // 输入参数类型
    type State = (f64, i64);  // 状态:(sum, count)
    type Finalize = f64;  // 返回类型
    
    fn state(
        current: Self::State,
        arg: Self::Args,
        _fcinfo: pg_sys::FunctionCallInfo
    ) -> Self::State {
        (current.0 + arg, current.1 + 1)
    }
    
    fn finalize(
        state: Self::State,
        _fcinfo: pg_sys::FunctionCallInfo
    ) -> Self::Finalize {
        if state.1 == 0 {
            0.0
        } else {
            state.0 / state.1 as f64
        }
    }
}

在SQL中使用:

-- 创建聚合
CREATE AGGREGATE moving_avg(f64) (
    SFUNC = moving_avg_trans,
    STYPE = RECORD,
    FINALFUNC = moving_avg_final
);

-- 使用
SELECT moving_avg(score) FROM exam_results WHERE subject = '数学';

第四部分:性能优化与高级技巧

4.1 零拷贝与内存映射

在处理大对象(LOB)或二进制数据时,避免不必要的内存拷贝至关重要。

使用PgVarlena实现零拷贝访问

use pgrx::varlena::PgVarlena;

#[pg_extern]
fn process_binary(data: PgVarlena) -> Vec<u8> {
    // 直接访问PG的varlena结构体,无需拷贝
    let slice: &[u8] = data.as_bytes();
    
    // 处理数据
    let mut result = Vec::with_capacity(slice.len());
    for &byte in slice {
        result.push(byte.wrapping_add(1));
    }
    
    result
}

内存映射文件

#[pg_extern]
fn mmap_process_file(filepath: &str) -> i64 {
    use memmap2::Mmap;
    use std::fs::File;
    
    let file = File::open(filepath).expect("无法打开文件");
    let mmap = unsafe { Mmap::map(&file).expect("mmap失败") };
    
    // 直接在mmap上操作,无需读取到Rust堆
    let sum: i64 = mmap.iter().map(|&b| b as i64).sum();
    
    sum
}

4.2 并行查询支持

PostgreSQL 9.6+支持并行查询,pgrx扩展也可以利用这一特性。

标记函数为PARALLEL SAFE

#[pg_extern(parallel_safe)]
fn my_parallel_func(n: i64) -> i64 {
    // 此函数可以在并行worker中执行
    n * 2
}

实现并行聚合

#[pg_aggregate]
impl Aggregate for ParallelSum {
    // ... 其他实现 ...
    
    const PARALLEL: Option<ParallelOption> = Some(ParallelOption::Safe);
    
    // 合并来自不同worker的状态
    fn combine(
        state1: Self::State,
        state2: Self::State
    ) -> Self::State {
        (state1.0 + state2.0, state1.1 + state2.1)
    }
}

4.3 与Tokio异步运行时集成

在现代应用中,扩展可能需要访问异步API(如HTTP请求、异步数据库客户端)。pgrx允许你嵌入Tokio运行时:

use tokio::runtime::Runtime;

// 全局运行时(懒初始化)
lazy_static::lazy_static! {
    static ref TOKIO_RT: Runtime = Runtime::new().expect("无法创建Tokio运行时");
}

#[pg_extern]
fn fetch_url(url: &str) -> String {
    TOKIO_RT.block_on(async {
        let resp = reqwest::get(url).await
            .map_err(|e| pgrx::Error::new(e.to_string()))?;
        
        let body = resp.text().await
            .map_err(|e| pgrx::Error::new(e.to_string()))?;
        
        Ok::<String, pgrx::Error>(body)
    }).expect("异步请求失败")
}

注意:在PG的后端进程中使用异步运行时需要小心,因为:

  1. PG的信号处理可能与Tokio冲突
  2. 长时间运行的异步任务可能阻塞PG的查询执行器

更安全的做法是使用**SPI(Server Programming Interface)**与PG协同:

#[pg_extern]
fn async_to_sync_wrapper(url: &str) -> String {
    // 使用单独的线程运行异步代码
    let handle = std::thread::spawn(|| {
        TOKIO_RT.block_on(async {
            reqwest::get(url).await?.text().await
        })
    });
    
    // 在等待时,可以执行SPI操作
    Spi::run("NOTIFY url_fetch, 'started'");
    
    let result = handle.join().expect("线程崩溃").expect("请求失败");
    result
}

第五部分:测试与部署

5.1 pgrx的测试框架

pgrx提供了pg_test宏,可以在真实的PostgreSQL实例中运行集成测试:

#[cfg(test)]
mod tests {
    use pgrx::prelude::*;
    use pgrx::pg_test;
    
    #[pg_test]
    fn test_hello_pgrx() {
        let result = crate::hello_pgrx("测试");
        assert_eq!(result, "Hello, 测试! Welcome to Rust + PostgreSQL.");
    }
    
    #[pg_test]
    fn test_moving_avg() {
        // 使用SPI执行SQL测试
        let result = Spi::get_one::<f64>(
            "SELECT moving_avg(score) FROM (VALUES (1.0), (2.0), (3.0)) AS t(score)"
        ).expect("查询失败").expect("结果为NULL");
        
        assert!((result - 2.0).abs() < 1e-10);
    }
}

运行测试:

cargo pgrx test pg16

5.2 性能基准测试

使用Criterion.rs进行基准测试:

use criterion::{criterion_group, criterion_main, Criterion};

fn benchmark_process_binary(c: &mut Criterion) {
    let data = vec![0u8; 1024 * 1024];  // 1MB数据
    
    c.bench_function("process_binary/1MB", |b| {
        b.iter(|| {
            crate::process_binary(pgrx::varlena::PgVarlena::from_bytes(&data))
        })
    });
}

criterion_group!(benches, benchmark_process_binary);
criterion_main!(benches);

5.3 生产环境部署

编译优化

# 使用LTO(链接时优化)和特定CPU指令集
RUSTFLAGS="-C target-cpu=native -C lto=fat" \
cargo pgrx run pg16 --release

打包为PGXN发行版

# 创建分发包
cargo pgrx package

# 生成的文件结构
target/release/pg_my_extension-0.1.0-pg16.el8.x86_64.rpm

在 production 中安装

-- 方式1:从SQL安装
CREATE EXTENSION pg_my_extension;

-- 方式2:手动安装.so文件
LOAD '/usr/pgsql-16/lib/pg_my_extension.so';

第六部分:实战案例——构建一个完整的全文搜索扩展

让我们将前面学到的知识整合起来,构建一个支持中文分词的全文搜索扩展

6.1 需求分析

我们要实现:

  1. 使用jieba-rs进行中文分词
  2. 创建自定义类型ChineseVector存储向量化的文档
  3. 实现@@操作符支持ChineseVector的相似度匹配
  4. 支持GIN索引加速查询

6.2 实现代码

use jieba_rs::Jieba;
use serde::{Serialize, Deserialize};
use std::sync::OnceLock;

// 全局分词器(懒初始化)
static JIEBA: OnceLock<Jieba>() = OnceLock::new();

fn get_jieba() -> &'static Jieba {
    JIEBA.get_or_init(|| Jieba::new().expect("加载词典失败"))
}

// 自定义类型:中文向量
#[derive(PostgresType, Serialize, Deserialize)]
struct ChineseVector {
    terms: Vec<String>,
    weights: Vec<f32>,
}

impl ChineseVector {
    fn from_text(text: &str) -> Self {
        let jieba = get_jieba();
        let words: Vec<&str> = jieba.cut(text, false).collect();
        
        // TF-IDF简化版:统计词频
        let mut tf = std::collections::HashMap::new();
        for word in words {
            *tf.entry(word.to_string()).or_insert(0.0) += 1.0;
        }
        
        let terms: Vec<String> = tf.keys().cloned().collect();
        let total: f32 = tf.values().sum();
        let weights: Vec<f32> = tf.values().map(|&c| c / total).collect();
        
        ChineseVector { terms, weights }
    }
    
    fn cosine_similarity(&self, other: &ChineseVector) -> f32 {
        // 构建稀疏向量点积
        let mut dot = 0.0;
        for (i, term) in self.terms.iter().enumerate() {
            if let Some(j) = other.terms.iter().position(|t| t == term) {
                dot += self.weights[i] * other.weights[j];
            }
        }
        
        // 归一化
        let norm_self: f32 = self.weights.iter().map(|w| w * w).sum();
        let norm_other: f32 = other.weights.iter().map(|w| w * w).sum();
        
        if norm_self > 0.0 && norm_other > 0.0 {
            dot / (norm_self.sqrt() * norm_other.sqrt())
        } else {
            0.0
        }
    }
}

// 创建ChineseVector的构造函数
#[pg_extern]
fn to_chinese_vector(text: &str) -> ChineseVector {
    ChineseVector::from_text(text)
}

// 实现 @@ 操作符
#[pg_operator]
#[opname = "@@"]
fn chinese_match(vec1: ChineseVector, vec2: ChineseVector) -> bool {
    vec1.cosine_similarity(&vec2) > 0.5  // 阈值0.5
}

// GIN支持(简化版)
#[pg_extern]
fn chinese_vector_gin_extract(ve: ChineseVector) -> Vec<String> {
    ve.terms
}

6.3 在SQL中使用

CREATE EXTENSION chinese_search;

-- 创建表
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title TEXT,
    content TEXT,
    vec ChineseVector
);

-- 插入数据(自动向量化)
INSERT INTO articles (title, content, vec)
VALUES ('Rust与PostgreSQL', 'Rust是一门系统编程语言', to_chinese_vector('Rust是一门系统编程语言'));

-- 搜索
SELECT * FROM articles
WHERE vec @@ to_chinese_vector('系统编程');
-- 返回相似度 > 0.5 的文章

第七部分:常见问题与调试技巧

7.1 使用pgrx::log进行调试

#[pg_extern]
fn debug_func() {
    pgrx::log!("这是INFO级别日志");
    pgrx::debug!("调试信息: value = {}", 42);
    pgrx::error!("严重错误!");
}

postgresql.conf中配置日志级别:

log_min_messages = DEBUG1
log_statement = 'all'

7.2 使用GDB调试pgrx扩展

# 1. 以调试模式启动PG
pg_ctl start -D /path/to/data -l logfile

# 2. 找到后端进程PID
ps aux | grep postgres

# 3. 使用GDB附加
gdb -p <PID>
(gdb) set pagination off
(gdb) break rust_panic
(gdb) continue

7.3 常见编译错误

错误:pgrx::pg_sys不完整类型

解决:确保在Cargo.toml中启用了正确的pgXX feature:

[features]
default = ["pg16"]
pg16 = ["pgrx/pg16"]

错误:链接失败,找不到pg_xxx符号

解决:确保cargo pgrx run使用的PG版本与编译时一致。


总结与展望

通过本文,我们深入探讨了:

  1. pgrx的架构设计:如何通过FFI边界保证内存安全
  2. 基础与高级扩展开发:从简单函数到FDW、自定义类型、聚合函数
  3. 性能优化:零拷贝、并行查询、异步运行时集成
  4. 测试与部署:完整的CI/CD流程
  5. 实战案例:构建中文全文搜索扩展

pgrx的未来方向(2026-2027):

  • WASM支持:将pgrx扩展编译为WebAssembly,在边缘计算场景中运行PG扩展
  • GPU加速:通过CUDA/ROCm在PG扩展中加速数值计算
  • 分布式扩展:与Citus深度集成,支持跨分片的函数下推

参考资源

  • pgrx官方文档:https://github.com/pgcentralfoundation/pgrx
  • PostgreSQL内部机制:https://www.interdb.jp/pg/
  • 本文完整代码示例:https://github.com/程序员茄子/pgrx-examples-2026

关于作者
程序员茄子,全栈工程师,PostgreSQL贡献者,Rust生态爱好者。曾在生产环境中部署过10+个pgrx扩展,单节点日均处理10亿+次查询。

License:本文采用CC BY-NC-SA 4.0协议,代码采用MIT协议。

复制全文 生成海报 Rust PostgreSQL pgrx 数据库扩展 FDW

推荐文章

php客服服务管理系统
2024-11-19 06:48:35 +0800 CST
任务管理工具的HTML
2025-01-20 22:36:11 +0800 CST
联系我们
2024-11-19 02:17:12 +0800 CST
liunx宝塔php7.3安装mongodb扩展
2024-11-17 11:56:14 +0800 CST
Python 微软邮箱 OAuth2 认证 Demo
2024-11-20 15:42:09 +0800 CST
php strpos查找字符串性能对比
2024-11-19 08:15:16 +0800 CST
智慧加水系统
2024-11-19 06:33:36 +0800 CST
Flet 构建跨平台应用的 Python 框架
2025-03-21 08:40:53 +0800 CST
Vue中如何使用API发送异步请求?
2024-11-19 10:04:27 +0800 CST
Nginx 防盗链配置
2024-11-19 07:52:58 +0800 CST
开发外贸客户的推荐网站
2024-11-17 04:44:05 +0800 CST
全栈工程师的技术栈
2024-11-19 10:13:20 +0800 CST
使用xshell上传和下载文件
2024-11-18 12:55:11 +0800 CST
程序员茄子在线接单