Skip to content

开发自定义扩展 ​

前面五篇讲的都是「用别人的扩展」。当你的业务里出现一段反复复制粘贴的 SQL、一个用 PL/pgSQL 写得又慢又难维护的函数、或者需要访问内核内部状态(如缓冲区、WAL、执行器钩子)时,就该考虑把它打包成自己的扩展。

PostgreSQL 的扩展开发门槛比多数人想象的低。一个只含 SQL 对象的扩展,只需要两个纯文本文件:一个控制文件(.control)和一个安装脚本(.sql)。不需要编译、不需要 C 语言、不需要重启数据库。而当你确实需要性能或内核能力时,再往上叠加 C 函数与 PGXS 构建系统。

本篇按「控制文件 → SQL 脚本 → 安装验证 → 版本升级 → C 函数 → PGXS 构建 → 发布」这条完整链路展开,每一步都给出可直接落地的文件与命令。

前置知识

本篇假定你已读过 Citus 分布式扩展,理解扩展如何以「元数据 + 对象集合」的形式存在;同时强烈建议先读 CREATE EXTENSION 扩展管理,因为本篇涉及的控制文件属性(superuser / trusted / relocatable)与 pg_extension 目录表都在那一篇详述过。

基础概念 ​

一个扩展由什么组成 ​

按「必须 / 可选」划分,一个扩展的文件构成如下:

文件是否必须作用
<name>.control必须扩展的元信息:默认版本、schema、权限属性、依赖
<name>--<version>.sql必须安装脚本,包含所有 SQL 对象的 CREATE 语句
<name>--<v1>--<v2>.sql可选版本升级脚本,从 v1 升到 v2 的增量 DDL
<name>.so / <name>.dylib可选C 共享库,供 SQL 中的 MODULE_PATHNAME 引用
<name>.c / .h / Makefile可选源码与构建脚本(仅开发时存在,不随安装分发)
README / LICENSE推荐说明与许可

安装后,前几项会被复制到 PostgreSQL 的 share/extension/ 与 lib/ 目录:

bash
pg_config --sharedir    # /usr/local/pgsql/share
pg_config --pkglibdir   # /usr/local/pgsql/lib

扩展的 .control 与 .sql 放在 $(pg_config --sharedir)/extension/,.so 放在 $(pg_config --pkglibdir)/。

控制文件(Control File)的字段 ​

控制文件是纯文本,语法是 key = value,每行一个(# 开头是注释)。所有字段:

字段默认说明
comment无扩展的一句话描述,显示在 pg_available_extensions.comment
default_version无默认安装版本,必须
directory扩展名脚本所在子目录(相对于 share/extension)
module_pathname无替换脚本中 MODULE_PATHNAME 的路径
requires无依赖的其他扩展名(逗号分隔)
superusertrue是否只有超级用户能安装
trustedfalse是否为可信扩展(PG13+,允许非超级用户安装)
relocatablefalse是否可用 ALTER EXTENSION SET SCHEMA 整体搬家
schema无强制安装到指定 schema(设置后扩展不可 relocatable)
encoding无脚本文件编码

一个最小可用的控制文件:

text
# pg_slug.control
comment = 'URL-safe slug generation helpers'
default_version = '1.0'
relocatable = true

SQL 脚本的命名规则 ​

脚本文件名严格遵循 <扩展名>--<版本>.sql:--1.0.sql 是从零安装 1.0 的安装脚本,--1.0--1.1.sql 是从 1.0 升到 1.1 的升级脚本。规则要点:

  • PostgreSQL 会尝试找一条从当前版本到目标版本的升级路径(可以多步,如 1.0 → 1.1 → 2.0)。
  • 若只提供 --1.0--2.0.sql(一步跨越),那么 1.1 无法升级到 2.0。
  • --<版本>.sql 与升级脚本可以共存,PostgreSQL 优先用最短路径。

MODULE_PATHNAME、$libdir 与占位符 ​

SQL 脚本里引用共享库时不要写死路径:

sql
CREATE FUNCTION @extschema@.slugify(text) RETURNS text
AS 'MODULE_PATHNAME', 'pg_slugify'
LANGUAGE C IMMUTABLE STRICT;

MODULE_PATHNAME 会被控制文件的 module_pathname 替换;若未设置,则默认替换为 $libdir/<扩展名>。这是让扩展在不同安装路径下都能工作的关键。也可以用显式 $libdir(多库共享时有用):

sql
AS '$libdir/pg_slug', 'pg_slugify'

脚本里的两个特殊占位符:

  • @extschema@:会被替换成扩展安装的 schema 名。写跨对象引用时必须用它,否则脚本可能在非 public 的 schema 下失败。
  • @extowner@:会被替换成执行 CREATE EXTENSION 的角色名(PG13+),用于需要显式 ALTER ... OWNER TO 的场景。

CREATE EXTENSION 的内部行为 ​

理解这一点能解释很多「奇怪现象」。官方文档说明,安装时 PostgreSQL 会在一个受限的搜索路径下执行脚本,等价于:

sql
SET LOCAL search_path TO @extschema@, pg_temp;

含义:

  • 脚本执行时,当前 schema 是 @extschema@,pg_temp 排第二。
  • 脚本里没有 schema 限定的对象会创建到 @extschema@;引用的其他对象若不带 schema,可能解析到 pg_temp 或直接找不到。
  • 用户自己的 search_path 设置不会影响脚本执行。

另外,PostgreSQL 会扫描脚本创建的所有对象并把它们登记进 pg_depend,使 DROP EXTENSION 能级联清理。

relocatable 与 schema 的关系 ​

relocatable = true 的前提是脚本里所有对象引用都用 @extschema@ 限定,且不依赖任何硬编码的 schema 名。推论:

  • 设了 schema = foo 的扩展不能是 relocatable(因为强制了位置)。
  • 一旦脚本里出现 public.something,即使声明 relocatable = true 也可能在搬 schema 后出错。
  • ALTER EXTENSION name SET SCHEMA new_schema 之所以能把所有对象一起搬,靠的是 pg_depend 里的 e 依赖记录。

trusted 扩展的编写约束 ​

要让扩展能被普通用户安装(trusted = true),需要满足(PG13+):

  1. 控制文件里 superuser = false 或 trusted = true。
  2. 安装脚本不执行任何需要超级用户的动作(不创建 LANGUAGE C 函数、不改参数、不写文件系统)。
  3. 脚本里的对象不能被滥用提权 —— 这是核心:纯 SQL/PL 语言、且所有函数正确设置 SECURITY INVOKER、不给不必要权限的扩展才适合 trusted。

一句话:trusted 是对「脚本安全性」的人工承诺,PostgreSQL 不会验证你的承诺。

语法与用法 ​

从零写一个纯 SQL 扩展 ​

以「生成 URL 安全 slug」为例,只需两个文件。

第一步,控制文件:

text
# pg_slug.control
comment = 'URL-safe slug generation helpers'
default_version = '1.0'
relocatable = true

第二步,安装脚本:

sql
-- pg_slug--1.0.sql

-- complain if script is sourced in psql, rather than via CREATE EXTENSION
\echo Use "CREATE EXTENSION pg_slug" to load this file. \quit

CREATE FUNCTION @extschema@.slugify(txt text)
RETURNS text
AS $$
    SELECT regexp_replace(
               regexp_replace(lower(trim(txt)), '[^a-z0-9]+', '-', 'g'),
               '(^-|-$)', '', 'g'
           );
$$
LANGUAGE sql
IMMUTABLE
STRICT
PARALLEL SAFE;

第三步,把两个文件复制到扩展目录并安装:

bash
cp pg_slug.control pg_slug--1.0.sql "$(pg_config --sharedir)/extension/"
sql
CREATE EXTENSION pg_slug;
SELECT pg_slug.slugify('Hello PostgreSQL World!');
--  hello-postgresql-world

若脚本里要调用其他扩展提供的函数(例如 unaccent),必须在控制文件里用 requires 声明依赖,否则解析会失败。

标准的脚本头部与尾部 ​

PostgreSQL 自己维护的 contrib 扩展都遵循这套约定,强烈建议照抄:

sql
-- 1. 头部:防止脚本被 psql 直接 source
\echo Use "CREATE EXTENSION my_ext" to load this file. \quit
sql
-- 2. 尾部:把配置表标记为「需要被 pg_dump 导出」
SELECT pg_catalog.pg_extension_config_dump('my_ext_config', '');

第二条非常关键:默认情况下 pg_dump 只导出 CREATE EXTENSION 一行,不会导出扩展自己创建的表里的数据。如果扩展带「配置表」或「字典表」,必须调用 pg_extension_config_dump() 把它标为「配置表」,否则备份恢复后数据全丢。它还支持 WHERE 条件,用于「只备份部分行」:

sql
-- 只 dump 未处理完的行(注意 SQL 字符串里的单引号要写成两个)
SELECT pg_catalog.pg_extension_config_dump('my_ext_queue', 'WHERE status <> ''done''');

版本升级脚本 ​

假设 1.0 只有 slugify,1.1 给 slugify 加一个可选参数。新建升级脚本(只改动部分):

sql
-- pg_slug--1.0--1.1.sql

-- PostgreSQL 不支持修改函数参数列表,必须先 DROP 再建
DROP FUNCTION @extschema@.slugify(text);

CREATE FUNCTION @extschema@.slugify(txt text, max_len int DEFAULT NULL)
RETURNS text
AS $$
    SELECT CASE WHEN max_len IS NULL THEN base ELSE left(base, max_len) END
    FROM (SELECT regexp_replace(
              regexp_replace(lower(trim(txt)), '[^a-z0-9]+', '-', 'g'),
              '(^-|-$)', '', 'g') AS base) s;
$$
LANGUAGE sql IMMUTABLE PARALLEL SAFE;   -- 注意:max_len 默认 NULL,不能标 STRICT

同时在 --1.1.sql(从零安装 1.1)里放完整定义,并更新控制文件的 default_version = '1.1':

sql
-- pg_slug--1.1.sql(片段)
CREATE FUNCTION @extschema@.slugify(txt text, max_len int DEFAULT NULL) ...;

执行升级并查看可选版本:

sql
ALTER EXTENSION pg_slug UPDATE;
-- 或指定版本
ALTER EXTENSION pg_slug UPDATE TO '1.1';

SELECT name, version, installed FROM pg_available_extension_versions
WHERE name = 'pg_slug' ORDER BY version;

注意

DROP FUNCTION 会连带删除依赖它的对象(视图、其他函数)。大版本升级时若涉及 DROP,先确认依赖图,必要时用 CREATE OR REPLACE 保持签名不变。

声明依赖:requires ​

如果新函数依赖 unaccent:

text
comment = 'URL-safe slug generation helpers'
default_version = '1.1'
relocatable = true
requires = 'unaccent'

CREATE EXTENSION pg_slug; 会要求 unaccent 已安装(否则报错),DROP EXTENSION pg_slug; 会因依赖被拒(除非 CASCADE)。查看扩展依赖:

sql
SELECT objid::regprocedure AS dependent,
       refobjid::regprocedure AS depends_on
FROM pg_depend
WHERE deptype = 'e'
  AND refobjid = (SELECT oid FROM pg_extension WHERE extname = 'pg_slug');

安装检查与删除 ​

sql
CREATE EXTENSION pg_slug;

-- 检查文件被识别
SELECT name, default_version, comment FROM pg_available_extensions WHERE name = 'pg_slug';

-- 检查对象归属
SELECT e.extname, e.extversion, e.extnamespace::regnamespace AS schema, e.extrelocatable
FROM pg_extension e WHERE e.extname = 'pg_slug';

查看扩展拥有的所有对象,并确认 deptype:

sql
SELECT classid::regclass AS catalog, objid, deptype
FROM pg_depend
WHERE refclassid = 'pg_extension'::regclass
  AND refobjid = (SELECT oid FROM pg_extension WHERE extname = 'pg_slug')
ORDER BY catalog;
text
       catalog       | objid | deptype
---------------------+-------+---------
 pg_namespace        | 16385 | n
 pg_proc             | 16386 | e
 pg_proc             | 16387 | e

deptype = 'e' 表示「由扩展拥有」,DROP EXTENSION 会删掉这些对象:

sql
DROP EXTENSION pg_slug;              -- 有依赖会报错
DROP EXTENSION pg_slug CASCADE;      -- 连带删依赖对象

日常排查用 psql 元命令最快:

text
\dx                  -- 已安装扩展一览
\dx+ pg_slug         -- 某扩展拥有的所有对象
\dx pg_slug          -- 某扩展的版本与 schema
\df pg_slug.*        -- 扩展内的函数

详细说明 ​

控制文件逐字段实战 ​

一个「真实感」较强的控制文件,把可用字段都用上:

text
# myapp_ext.control
comment = 'MyApp internal helpers: audit, slug, soft-delete'
default_version = '2.3'
module_pathname = '$libdir/myapp_ext'
requires = 'pgcrypto'
superuser = true
trusted = false
relocatable = false
schema = extensions
  • module_pathname:所有 AS 'MODULE_PATHNAME', ... 会被替换成 $libdir/myapp_ext。
  • requires = 'pgcrypto':CREATE EXTENSION myapp_ext; 会要求 pgcrypto 已存在,但不会自动安装 —— 这与 CASCADE 的行为不同。
  • schema = extensions:强制装到 extensions,同时意味着 relocatable = false(位置被钉死)。
  • superuser = true:约定了只有超级用户能装。

脚本里必须写全限定名 ​

这是新手最容易踩的坑。看一个反例:

sql
-- 错误示范:引用 public 下的表,且不带 schema
CREATE FUNCTION @extschema@.log_change() RETURNS trigger AS $$
BEGIN
    INSERT INTO audit_log (tbl, op, at) VALUES (TG_TABLE_NAME, TG_OP, now());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

问题:脚本执行时 search_path 是 @extschema@, pg_temp,audit_log 若在 public 下就找不到;更糟的是可能解析到 pg_temp 下的同名临时表(这是 PostGIS 等扩展历史上出现过的一类安全问题)。正确写法:

sql
CREATE FUNCTION @extschema@.log_change() RETURNS trigger AS $$
BEGIN
    INSERT INTO @extschema@.audit_log (tbl, op, at)
    VALUES (TG_TABLE_NAME, TG_OP, now());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

铁律:脚本里所有对象引用(表、函数、类型、操作符)都写全限定名,且 schema 部分用 @extschema@。

SET search_path 的写法 ​

给函数设置 search_path 是防御 SQL 注入与解析歧义的标准做法。注意两点:

sql
-- 不推荐:只设 public,可能被 pg_temp 劫持
CREATE FUNCTION @extschema@.safe_fn() RETURNS int
LANGUAGE sql SECURITY DEFINER
SET search_path = public
AS $$ SELECT 1 $$;

-- 推荐:显式含 pg_temp,且放在最后
CREATE FUNCTION @extschema@.safe_fn() RETURNS int
LANGUAGE sql SECURITY DEFINER
SET search_path = @extschema@, pg_temp
AS $$ SELECT 1 $$;

SET search_path 出现在 CREATE FUNCTION 的属性列表里,会随函数定义一起存进 pg_proc.proconfig,每次调用时生效,不受调用者环境影响。

pg_extension_config_dump() 的语义 ​

sql
SELECT pg_catalog.pg_extension_config_dump('myapp_ext.config', '');

它做了两件事:往 pg_extension 的 extconfig 数组里加入该表的 OID,往 extcondition 数组里加入对应条件(第二个参数)。pg_dump 看到 extconfig 里的表,就会额外导出它的数据(而非只导出 CREATE EXTENSION)。

场景是否该标记
用户自定义配置(阈值、开关)标记(必须)
缓存表、临时结果不标记
只读字典(随扩展发版)不标记
用户创建的业务数据(恰好放在扩展 schema)不标记

extcondition 支持 WHERE 子句,用于「只备份未处理的行」这类场景:

sql
SELECT pg_catalog.pg_extension_config_dump('myapp_ext.job_queue',
       'WHERE status <> ''done''');

C 函数的接口约定 ​

写 C 函数需要遵守几条固定约定。

第一,声明模块魔数(每个 .c 文件一次):

c
#include "postgres.h"
#include "fmgr.h"

PG_MODULE_MAGIC;

PG18 引入了带 ABI 信息的 PG_MODULE_MAGIC_EXT,可在其中声明扩展名与版本,便于自省(具体字段与用法以所用版本的官方文档为准)。

第二,声明函数可见性,并用三件套签名:

c
PG_FUNCTION_INFO_V1(pg_add_one);

Datum pg_add_one(PG_FUNCTION_ARGS)
{
    int32 arg = PG_GETARG_INT32(0);
    PG_RETURN_INT32(arg + 1);
}
  • 返回类型必须是 Datum;参数只有一个 PG_FUNCTION_ARGS(它展开为一个参数列表)。
  • 用 PG_GETARG_* 取参数、PG_RETURN_* 返回结果,不要手工构造 Datum。

第三,常用宏速查:

宏用途
PG_GETARG_INT32(n)取第 n 个参数为 int32
PG_GETARG_TEXT_PP(n)取 text(可能为短头,只读)
PG_GETARG_CSTRING(n)取 cstring
PG_GETARG_BOOL(n)取 bool
PG_ARGISNULL(n)判断参数是否为 NULL(非 STRICT 函数需要)
PG_RETURN_INT32(x) / PG_RETURN_BOOL(x)返回对应类型
PG_RETURN_TEXT_P(x)返回 text
PG_RETURN_NULL() / PG_RETURN_VOID()返回 NULL / void

第四,在 SQL 脚本里注册,并保证语义标注与实现一致:

sql
CREATE FUNCTION @extschema@.pg_add_one(integer) RETURNS integer
AS 'MODULE_PATHNAME', 'pg_add_one'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

IMMUTABLE / STRICT / PARALLEL SAFE 必须与实际行为一致,否则会产生错误结果或崩溃。这三条是最常见的 C 扩展 bug 来源。

PGXS 构建系统与调试 ​

PGXS(PostgreSQL Extension Building Infrastructure)让扩展的构建脚本只有几行。Makefile:

makefile
# 扩展名
EXTENSION = myapp_ext
# 要安装的数据文件(控制文件 + 所有 sql 脚本)
DATA = myapp_ext--1.0.sql myapp_ext--1.0--1.1.sql myapp_ext.control
# 要编译并安装的共享库
MODULE_big = myapp_ext
OBJS = myapp_ext.o

PG_CONFIG ?= pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

常用目标:

bash
make                     # 编译(若有 C 源码)
sudo make install        # 安装到 share/extension 与 pkglibdir
make installcheck        # 运行回归测试(需 REGRESS 变量)
make clean               # 清理

只有 SQL 脚本、没有 C 源码时,去掉 MODULE_big / OBJS 两行即可。make install 会自动把 DATA 里的文件复制到 $(pg_config --sharedir)/extension/,把 MODULE_big 的目标复制到 $(pg_config --pkglibdir)/。

调试 C 扩展的三种常用手段:往服务器日志输出(elog(LOG, ...)、ereport(ERROR, ...));只做 make 快速验证编译;用 gdb -p $(pgrep -f "postgres.*myapp") 附加到后端再 break 目标函数。建议在开发机上用以 --enable-debug --enable-cassert 编译的 PostgreSQL 测试:断言(cassert)能抓出很多内存与状态错误。

回归测试(REGRESS)与目录结构 ​

PGXS 内置了回归测试框架。在 Makefile 里加一行 REGRESS = basic slug upgrade,并准备 sql/basic.sql(输入)与 expected/basic.out(期望输出),再运行:

bash
make installcheck

它会启动一个测试数据库、执行 sql/*.sql、把输出与 expected/*.out 逐字符比较。这是 PostgreSQL 生态里扩展测试的事实标准,写扩展就该配回归测试。

推荐的目录布局(与 contrib 扩展一致):

text
myapp_ext/
├── Makefile
├── myapp_ext.control
├── myapp_ext--1.0.sql
├── myapp_ext--1.0--1.1.sql
├── myapp_ext.c
├── sql/{basic,upgrade}.sql
├── expected/{basic,upgrade}.out
├── README.md
└── LICENSE

权限:SECURITY DEFINER 与 pg_init_privs ​

若扩展需要以「扩展拥有者」身份执行(如写审计表),用 SECURITY DEFINER 并锁死 search_path:

sql
CREATE FUNCTION @extschema@.log_audit(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = @extschema@, pg_temp
AS $$
BEGIN
    INSERT INTO @extschema@.audit_log (payload, at) VALUES (payload, now());
END;
$$;

反模式(危险!):

sql
-- 错误!没锁 search_path,可能被劫持
CREATE FUNCTION log_audit(payload jsonb) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$ INSERT INTO audit_log (payload) VALUES (payload); $$;

攻击者只要在自己 schema 下建一个同名 audit_log 并把该 schema 放进 search_path,就能让这个「以属主身份运行」的函数往自己控制的表里写数据。

另外,扩展创建的对象上若有非默认权限,PostgreSQL 会把它们记进 pg_init_privs,以便 ALTER EXTENSION UPDATE 后权限保持一致:

sql
-- 在安装脚本里授权,这条 GRANT 会被记录
GRANT EXECUTE ON FUNCTION @extschema@.slugify(text) TO PUBLIC;

pg_dump 与扩展:必须知道的差异 ​

对象类型pg_dump 行为
扩展本身只输出 CREATE EXTENSION name WITH SCHEMA x;
扩展拥有的对象不输出(由 CREATE EXTENSION 重建)
extconfig 标记的表的数据输出
未标记的表的数据不输出
用户在扩展 schema 里自己建的对象输出(但它们不属于扩展)

推论:恢复时目标库必须已安装同名同版本的扩展二进制,否则 CREATE EXTENSION 失败。这是 pg_upgrade 前必须核对扩展清单的原因。

实战示例 ​

场景一:纯 SQL 工具扩展(无 C,trusted) ​

在 pg_slug/ 下放三个文件:控制文件(在前面「从零写一个纯 SQL 扩展」的基础上加 trusted = true)、安装脚本 pg_slug--1.0.sql,以及一个只有四行的 Makefile:

makefile
# Makefile
EXTENSION = pg_slug
DATA = pg_slug--1.0.sql pg_slug.control
PG_CONFIG ?= pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
bash
make install
sql
-- 验证 trusted:普通用户也能装
CREATE ROLE app_user LOGIN;
SET ROLE app_user;
CREATE EXTENSION pg_slug;
SELECT pg_slug.slugify('Hello World');   -- hello-world

场景二:带配置表的扩展 ​

只要扩展里出现了带数据的表,就必须在脚本末尾调用 pg_extension_config_dump():

sql
-- pg_slug--1.1.sql
\echo Use "CREATE EXTENSION pg_slug" to load this file. \quit

CREATE TABLE @extschema@.slug_rules (
    id      serial PRIMARY KEY,
    pattern text NOT NULL,
    replacement text NOT NULL
);

CREATE FUNCTION @extschema@.slugify(txt text)
RETURNS text
AS $$
    SELECT regexp_replace(
               regexp_replace(lower(trim(txt)), '[^a-z0-9]+', '-', 'g'),
               '(^-|-$)', '', 'g');
$$
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE;

-- 关键:把配置表标记为需要 dump
SELECT pg_catalog.pg_extension_config_dump('@extschema@.slug_rules', '');

验证:

bash
pg_dump --schema-only mydb | grep -n "slug_rules"

场景三:从 1.0 升级到 1.1 ​

升级脚本里重复一遍增量 DDL(内容与 --1.1.sql 中新增的部分一致,但只写增量):

sql
-- pg_slug--1.0--1.1.sql
CREATE TABLE @extschema@.slug_rules (
    id      serial PRIMARY KEY,
    pattern text NOT NULL,
    replacement text NOT NULL
);
SELECT pg_catalog.pg_extension_config_dump('@extschema@.slug_rules', '');

把控制文件的 default_version 改成 '1.1' 并 make install,然后:

sql
SELECT extversion FROM pg_extension WHERE extname = 'pg_slug';   -- 1.0
ALTER EXTENSION pg_slug UPDATE;
SELECT extversion FROM pg_extension WHERE extname = 'pg_slug';   -- 1.1

场景四:最小 C 扩展与 text/NULL 处理 ​

c
/* myapp_ext.c */
#include "postgres.h"
#include "fmgr.h"
#include "utils/builtins.h"

PG_MODULE_MAGIC;

PG_FUNCTION_INFO_V1(pg_add_one);
PG_FUNCTION_INFO_V1(pg_concat_text);
PG_FUNCTION_INFO_V1(pg_upper_or_null);

Datum
pg_add_one(PG_FUNCTION_ARGS)
{
    int32 arg = PG_GETARG_INT32(0);
    PG_RETURN_INT32(arg + 1);
}

Datum
pg_concat_text(PG_FUNCTION_ARGS)
{
    text *a = PG_GETARG_TEXT_PP(0);
    text *b = PG_GETARG_TEXT_PP(1);
    char *sa = text_to_cstring(a);
    char *sb = text_to_cstring(b);
    int   len = strlen(sa) + strlen(sb);
    text *result = (text *) palloc(len + VARHDRSZ);

    SET_VARSIZE(result, len + VARHDRSZ);
    memcpy(VARDATA(result), sa, strlen(sa));
    memcpy(VARDATA(result) + strlen(sa), sb, strlen(sb));

    pfree(sa);
    pfree(sb);
    PG_RETURN_TEXT_P(result);
}

Datum
pg_upper_or_null(PG_FUNCTION_ARGS)
{
    /* 非 STRICT 函数:必须自己判空 */
    if (PG_ARGISNULL(0))
        PG_RETURN_NULL();

    text *arg = PG_GETARG_TEXT_PP(0);
    char *s   = text_to_cstring(arg);
    text *out = cstring_to_text(s);   /* 简化示例,实际可用 upper 语义函数 */

    pfree(s);
    PG_RETURN_TEXT_P(out);
}

处理 varlena 的几个关键点:VARHDRSZ 是长度头的字节数,分配内存时必须加上;SET_VARSIZE() 必须调用,否则 PostgreSQL 读到的长度是垃圾值;palloc() 是 PostgreSQL 的内存分配,会在事务/函数结束时自动释放,不要用 malloc。

text
# myapp_ext.control
comment = 'MyApp helper functions'
default_version = '1.0'
module_pathname = '$libdir/myapp_ext'
relocatable = true
sql
-- myapp_ext--1.0.sql
\echo Use "CREATE EXTENSION myapp_ext" to load this file. \quit

CREATE FUNCTION @extschema@.pg_add_one(integer) RETURNS integer
AS 'MODULE_PATHNAME', 'pg_add_one'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

CREATE FUNCTION @extschema@.pg_concat_text(text, text) RETURNS text
AS 'MODULE_PATHNAME', 'pg_concat_text'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

-- 不能标 STRICT,因为需要自行处理 NULL
CREATE FUNCTION @extschema@.pg_upper_or_null(text) RETURNS text
AS 'MODULE_PATHNAME', 'pg_upper_or_null'
LANGUAGE C IMMUTABLE;
makefile
# Makefile
EXTENSION = myapp_ext
DATA = myapp_ext--1.0.sql myapp_ext.control
MODULE_big = myapp_ext
OBJS = myapp_ext.o

PG_CONFIG ?= pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
bash
make && sudo make install
sql
CREATE EXTENSION myapp_ext;
SELECT myapp_ext.pg_add_one(41);                    -- 42
SELECT myapp_ext.pg_concat_text('foo', 'bar');      -- foobar
SELECT myapp_ext.pg_upper_or_null(NULL);            -- NULL,不报错

场景五:加回归测试并跑通 ​

text
# sql/basic.sql
CREATE EXTENSION pg_slug;
SELECT pg_slug.slugify('Hello PostgreSQL World!');
SELECT pg_slug.slugify('---weird   spaces---');
DROP EXTENSION pg_slug;
text
# expected/basic.out
CREATE EXTENSION pg_slug;
    slugify
----------------
 hello-postgresql-world
(1 row)

SELECT pg_slug.slugify('---weird   spaces---');
  slugify
-----------
 weird-spaces
(1 row)

DROP EXTENSION pg_slug;

在 Makefile 里加 REGRESS = basic,然后 make installcheck。输出应以 # All 1 tests passed. 结束。

注意事项 ​

注意一:控制文件里 default_version 是必需的。

sql
-- 错误!控制文件里只有 comment,没有 default_version
CREATE EXTENSION my_ext;
-- ERROR:  extension "my_ext" has no default version

注意二:脚本里不要用 public. 硬编码。

sql
-- 错误!装到别的 schema 会失败,且破坏 relocatable
CREATE FUNCTION public.slugify(text) RETURNS text AS $$ ... $$ LANGUAGE sql;

注意三:忘记加 \echo ... \quit 头会让脚本被误执行。

不推荐直接 psql -f pg_slug--1.0.sql:用户会把对象塞进当前库且没有任何保护。头部的 \echo Use "CREATE EXTENSION x" to load this file. \quit 正是为了拦住这种用法。

注意四:配置表不加 pg_extension_config_dump() 会导致备份丢数据。

sql
-- 错误!表数据不会被 pg_dump 导出
CREATE TABLE @extschema@.slug_rules (...);
-- 缺少:SELECT pg_catalog.pg_extension_config_dump('@extschema@.slug_rules', '');

注意五:升级脚本里删除对象前先想清楚依赖。

-- 不推荐:直接 DROP FUNCTION @extschema@.slugify(text); —— 若已被视图或其他函数引用,会连带失败或级联删除。

注意六:CREATE FUNCTION 的语义标注必须与实现一致。

sql
-- 错误!实现对时间敏感却标了 IMMUTABLE
CREATE FUNCTION @extschema@.now_str() RETURNS text
AS 'MODULE_PATHNAME', 'now_str'
LANGUAGE C IMMUTABLE;   -- 会让规划器错误地常量折叠

注意七:SECURITY DEFINER 函数必须锁 search_path。

sql
-- 错误!可被 schema 劫持
CREATE FUNCTION log_audit(jsonb) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER AS $$ INSERT INTO audit_log VALUES ($1); $$;

注意八:C 代码里不能用 malloc / free。

c
/* 错误!内存不归 PostgreSQL 管理,可能泄漏或与异常处理冲突 */
char *buf = malloc(100);
free(buf);

/* 正确 */
char *buf = palloc(100);
pfree(buf);

注意九:varlena 结果必须设置 SET_VARSIZE。

c
/* 错误!长度字段是垃圾值 */
text *r = (text *) palloc(len + VARHDRSZ);
memcpy(VARDATA(r), s, len);
/* 缺少:SET_VARSIZE(r, len + VARHDRSZ); */

注意十:MODULE_PATHNAME 未配置会指向默认路径。

若 C 库名与扩展名不同,必须在控制文件里显式写 module_pathname = '$libdir/myapp_ext';否则 AS 'MODULE_PATHNAME', ... 会展开成 $libdir/<扩展名>,加载失败。

最佳实践 ​

  1. 从纯 SQL 扩展起步。90% 的内部工具需求用 LANGUAGE sql 或 plpgsql 就能满足,不要为了「性能」提前上 C。

  2. 脚本头部固定加 \echo Use "CREATE EXTENSION x" to load this file. \quit,与 PostgreSQL contrib 的写法保持一致。

  3. 脚本内所有引用一律全限定,schema 部分用 @extschema@ 而不是扩展名或 public。

  4. 默认开启 relocatable = true(前提是脚本规范),给使用者留出 ALTER EXTENSION SET SCHEMA 的余地。

  5. 带数据表的扩展必须调用 pg_extension_config_dump(),并用 extcondition 过滤掉不该备份的行。

  6. 维护完整的版本脚本链(--1.0.sql、--1.0--1.1.sql、--1.1.sql…),保证「从任意旧版本升级」与「从零安装最新版」都能走通。

  7. 为每个版本写回归测试(REGRESS + sql/ + expected/),make installcheck 通过才算完成。

  8. CREATE FUNCTION 的属性(IMMUTABLE / STRICT / PARALLEL SAFE / COST)必须与实现语义严格一致,这是最容易引入生产事故的地方。

  9. 所有 SECURITY DEFINER 函数显式 SET search_path = @extschema@, pg_temp,杜绝 schema 劫持。

  10. C 代码只用 palloc/pfree,并对所有 varlena 结果调用 SET_VARSIZE,避免内存与长度相关的崩溃。

  11. 在 --enable-cassert 的 PostgreSQL 上做开发测试,用断言提前暴露内存、状态与并发问题,再上生产验证。

  12. 把扩展纳入 CI 与发布流程:源码、Makefile、control、sql 全部进版本控制,产物由 CI 构建,安装版本由 pg_available_extension_versions 核对。

下一节

扩展系统这一组到此结束。下一篇进入 行级安全策略 RLS 概述,看 PostgreSQL 如何用 CREATE POLICY 把「谁能看哪些行」的规则下沉到数据库,为多租户与合规场景提供最后一道防线。

参考链接 ​