PBIDEA:用 uo_kv 做嵌入式键值库全解(LevelDB 目录落盘、类型化读写的 12 个重载与六类静默失败,PB10 基准 · PB12.5 实测)
本帖最后由 pbai 于 2026-11-12 05:58 编辑PBIDEA:用 uo_kv 做嵌入式键值库全解(LevelDB 目录落盘、类型化读写的 12 个重载与六类静默失败,PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:基准 PB10,兼容 PB 12.5(PBIDEA 组件 uo_kv,随版运行库为最新版;两版实测行为一致)
2. 支持数据库:本文不依赖任何外部数据库。uo_kv 自身是进程内的嵌入式键值库(LevelDB 形态),数据落在本地目录里
3. 操作系统与环境要求:Windows 7+(32 位 PowerBuilder 运行时),需已安装 PBIDEA 运行库(PbIdea.dll 与其配套 cs 库),并把 PBIDEA 的 websuite.pbl 加入库列表
4. 难度系数:★★★☆☆(会用 DataStore 就会用;难的是类型规则与几个静默失败点,本篇把它们逐个钉死)
5. 其它阅读说明:读之前最好知道 uo_map / uo_config 是什么(本文第七节给了三者选型对比)。示例全程用相对目录,能直接在应用目录跑
【版本声明】
- 本文以 PBIDEA 当前最新版本为准(版本源由维护方统一核验),并对照随版发布的源码整理。
- 核验日期:2026-11-12;当次核验的 PBIDEA 核心运行库:18,318,336 字节 / 2611 个导出,md5 25ac290414ab…。
- 版本识别口径:逐项解析 PE 导出表(文件体积、目录名都不能作为判据)。
- 适用平台:Windows XP 及以上(XP / 7 / 10 / 11 均可);PowerBuilder 8 及以上(PB 8/9/10/10.5/11.x/12.x 及以上通用)。本文的实机验证环境为 Windows 10 + PB 10 与 PB 12.5。
- PBIDEA 版本会持续更新,本文只对上述核验时点的最新版本负责;后续若行为有变,以新版为准。
一、uo_kv 是什么,为什么值得单独讲
uo_kv 是 PBIDEA 里的嵌入式键值库封装:一个 uo_kv 对象就是一个库句柄,Open 之后用 Set(键, 值) 写、用 Get(键, 变量) 读,数据落在磁盘上,关掉应用再打开还在。
它底下是 LevelDB(这点从落盘产物能直接确认,见第四节)。所以它有几个别处拿不到的特性:
[*]一个进程里零配置可用:不用装服务、不用建表、不用连库,Open('缓存目录','') 就完事;
[*]类型是带标签存的:写进去是 int,读出来还是 int;写进去是 double,读出来还是 double(但读写必须同族,第五节讲透);
[*]写入极快:实测 1000 次 Set 31 毫秒,1000 次 Get 快到计时器读不出来(第十三节);
[*]可以整库导出成 JSON(Get(uo_json)),做调试或迁移很方便(第十节)。
三者的分工,先用一句话分开,免得混用:
对象存储形态适合存什么不适合
uo_config单个 JSON 文件(可 SM4 加密)应用的配置项,需要人肉可读、可手改、可随包发大量键、频繁改写
uo_map纯内存(进程退出即消失)一次运行内的临时字典需要持久化
uo_kv目录形态的 LevelDB 库本地缓存、字典、离线队列、计数器、去重表需要 SQL 查询、多表关联
二、API 全貌(16 个声明,一个不多)
uo_kv 的公开面非常小,type prototypes 里一共 16 条(另加内部 create/destroy)。这张表就是它的全部:
成员签名说明
Openboolean Open(string dbName, string password)打开库。dbName 是目录名;password 为空表示不加密
Closesubroutine Close()关闭库
DeleteDBsubroutine DeleteDB()删除当前打开的库(调用后本实例报废)
DeleteDBsubroutine DeleteDB(string dbName)按名字删库(可删别的库)
Setboolean Set(string key, any value)写键值,值走 any
Getboolean Get(string key, ref <类型> value)按类型取值,共 12 个重载
Getboolean Get(ref uo_json json)整库导出成 JSON 对象
GetKeyListlong GetKeyList(ref string 动态数组)取全部键,返回键个数
Removeboolean Remove(string key)删键
那 12 个 Get 重载分别是:string、int、long、longlong、real、double、decimal、blob、date、time、datetime、any。
注意这里没有的东西(这几个「没有」后面都变成了坑,第五节细讲):
[*]没有 boolean 重载、没有 uint 重载、没有 ulong 重载 → 想读这几种类型,编译期就报 C0082;
[*]没有 Exists / ContainsKey → 判存在只能靠 Get 的返回值;
[*]没有 Count → 键数量只能靠 GetKeyList 返回的长度;
[*]没有 Clear / RemoveAll → 清库要么逐个 Remove,要么 DeleteDB 重建。
三、最小可运行:三步开库、写一个、读回来
前置:当前应用库的库列表(LibList)里已包含 PBIDEA 的 websuite.pbl,否则 uo_kv 类型无法解析。
步骤:1) 新建一个 nonvisualobject(或直接在窗口按钮事件里写);2) 贴入下段代码;3) 运行,看到弹窗里的 open/set/get 全 true 即通路正常。
// 库目录:相对应用当前目录,会在磁盘上建出同名目录(不是文件)
string ls_db
ls_db = 'demo_kv'
// 要写的键与值
string ls_key
ls_key = 'app.title'
string ls_val
ls_val = '订单系统'
// 密码传空串 = 不加密
string ls_pwd
ls_pwd = ''
uo_kv lk
boolean lb_open, lb_set, lb_get
string ls_back
lk = create uo_kv
lb_open = lk.Open(ls_db, ls_pwd)
lb_set = lk.Set(ls_key, ls_val)
ls_back = ''
lb_get = lk.Get(ls_key, ls_back)
lk.Close()
destroy lk
MessageBox('uo_kv 三步', 'open=' + String(lb_open) + ' set=' + String(lb_set) + ' get=' + String(lb_get) + ' 值=' + ls_back)
跑通后你会发现应用目录下多了个 demo_kv 文件夹——这就是第一件要纠正的直觉。
四、落盘形态:dbName 是目录,不是文件
很多人(包括我)第一次会写 FileLength('demo_kv') 去量库大小,然后拿到 -1 以为库没建成。实测就是这个读数:
open1=true len=-1 open2=true get=true v=
库不仅建成了,第二次打开还能读到上次写的值,但 FileLength 永远是 -1——因为 demo_kv 是个目录。进去看结构:
文件作用
CURRENT指向当前 MANIFEST
MANIFEST-000001版本清单
000003.log写入日志(数据主要躺在这里,直到压实)
LOCK库锁文件
LOG / LOG.oldLevelDB 自己的运行日志
由此得到几条实用结论:
[*]备份/迁移 = 拷整个目录,拷单个文件没有意义;
[*]不要往库目录里放自己的文件,也不要手工改里面的东西;
[*]目录不存在时 Open 会自动建,包括中间层级:Open('kv95_sub/dir1/kv.db','') 实测 open=true,两级子目录一起建出来;
[*]库名可以用中文:Open('数据.db','') 实测可用(PB 侧按 ANSI 路径传给底层,Windows 下正常);
[*]路径非法时 Open 返回 false,后续 Set 也一律 false,不崩(例:指向不存在的盘符 Z:/nope_dir/kv.db → open_bad_path=false set=false)。
五、类型规则:读写必须同族,失败不改写目标变量
这是 uo_kv 最容易吃亏的地方。Set 用 any 收值,底层按实际类型打标签存;Get 按你给的目标变量类型去匹配标签。匹配规则不是「能转就转」,而是「同族才转」。
先看正路(同类型往返,全部通过):
存的类型写法读回同类型实测读数
stringSet('s','hello')Get('s', ls)s=「hello」true
intSet('i', 7)Get('i', li)i=7true
longSet('l', 123456)Get('l', ll)l=123456true
doubleSet('d', 3.5)Get('d', ld)d=3.5true
decimalSet('dec', ldec)Get('dec', ldec2)dec=12.75true
realSet('real', lr)Get('real', lr2)real=1.5true
longlongSet('ll', 9000000000)Get('ll', lll)ll=9000000000true
dateSet('d', Date(2026,11,12))Get('d', ldt)d=2026-11-12true
timeSet('t', Time('08:30:15'))Get('t', ltm)t=08:30:15true
datetimeSet('dt', DateTime(...))Get('dt', ldtm)dt=2026-11-12 08:30:15true
blobSet('bin', Blob('AB'))Get('bin', lb)inlen=4 get=true outlen=4 eq=true
跨类型读的实测矩阵(这张表比上面那张重要):
存读成结果读数
int 42long✅ 通i2long=true「42」
int 42double❌ falsei2dbl=false「-1」
double 3.75int❌ falsed2int=false「-1」
double 3.75real✅ 通d2real=3.75
decimal 12.75double✅ 通dec2dbl=true「12.75」
double 12.75decimal✅ 通dbl2dec=true「12.75」
string '42'longlong / long / int❌ 全 falses2ll=false「-1」 s2long=false「-1」 s2int=false「-1」
string '7'int❌ falsestr2int=false「-1」
不存在的键任意类型❌ false,目标变量保持原值str=false「SENT」 i=-1 l=-1 d=-1
三条可以背下来的结论:
[*]整数族内部可以放宽(int→long),小数族内部可以互通(double↔decimal↔real),但整数与小数之间不互转——int 存进去,用 double 读会 false;double 存进去,用 int 读也 false。
[*]字符串数字不会被当数字:存的 '42' 用任何数值类型都读不出来。想要数字,写的时候就写数字。
[*]失败是「干净」的:返回 false,且目标变量一个字节都不改(上面 SENT/-1 就是预置的哨兵值)。这条是好事,可以放心用「预置默认值 → Get → 失败就用默认值」的写法。
六、最危险的地方:跨类型读「成功」但给垃圾
上面说的失败还算好,怕的是返回 true 但值不对。实测数值 → 字符串这条路上:
int2str=true[*] long2str=true[@?] dbl2str=true[] bool2str=false
[*]int 值用 string 读:返回 true,拿到的是 *;
[*]long 值用 string 读:返回 true,拿到的是乱码;
[*]double 值用 string 读:返回 true,拿到的是空串;
[*]blob 值用 string 读:blob2str=true len=1(4 字节的 UTF-16 blob 读成 1 个字符的乱码);
[*]string 值用 blob 读:str2blob=true len=5('hello' 变 5 字节 ANSI,而不是你预期的 UTF-16)。
这就是这篇最值钱的一条:Get 的返回值只代表「底层找到了这个键并且类型标签匹配成功」,不代表「值可以安全地当字符串用」。要字符串,就老老实实存字符串——别指望读的时候自动转。
补充一个对照:Get(key, any变量) 这条重载反而是忠实还原的,存什么类型就还什么类型:
date=true dbl=true str=true int=true
所以「类型不确定、就想原样拿出来看看」的场景,用 any 是对的;但 any 拿到手之后,取值前必须自己判类型,否则一样会踩上一段的坑。
七、Set(any) 的四个坑:boolean 存不进、uint 截断、ulong 变负、空串失联
Set 收 any,看着什么都能塞,实际有四种类型会出问题,全部实测确认:
① boolean 直接失败,连键都不会建
set=false n=0 int=false[-1] long=false[-1] any=false
Set('bkey', true) 返回 false,GetKeyList 返回 0——键根本没进库。想存布尔值,请存 1/0(int)或 'Y'/'N'(string),别存 boolean。
② uint 被截成 16 位,高位直接丢
u_any=true u_ll=10240
写入 uint 4000000000(0xEE6B2800),用 any 读回来是 10240(0x2800)——只剩低 16 位。这不是读的时候截的,是存的时候就没存全。uint 只适合 0~65535 的小值。
③ ulong 能存住,但读进 longlong 会变负
ul_any=true ul_ll=-1294967296
ulong 3000000000 用 any 读回正确(3000000000),但用 longlong 读回是 -1294967296(按 32 位有符号解释)。所以 ulong 值要么用 any 接,要么读进 ulong——而 ulong 没有重载(C0082)。结论:ulong 超过 2^31 时不要用 uo_kv 存。
④ 空字符串值存得进、读不回
set=true get=false len=4
Set('empty','') 返回 true,但 Get('empty', ls) 返回 false,ls 还是预置的 SENT(长度 4)。空串被当成「没有值」了。绕法很简单:写的时候把空串换成一个哨兵串,读的时候再换回来——
// 哨兵串:用来代表「值为空字符串」(uo_kv 存不进空串,Set 返回 true 但读不回)
// 注意:哨兵别用 ~ 开头,~ 是 PB 的转义符,写了会报 C0037
string ls_mark
ls_mark = '#EMPTY#'
// 要写的键
string ls_key
ls_key = 'memo'
// 要写的内容:空串
string ls_in
ls_in = ''
uo_kv lk
string ls_store, ls_back
boolean lb_set, lb_get
lk = create uo_kv
lk.Open('demo_kv', '')
ls_store = ls_in
if Len(ls_store) = 0 then ls_store = ls_mark
lb_set = lk.Set(ls_key, ls_store)
ls_back = ''
lb_get = lk.Get(ls_key, ls_back)
if lb_get and ls_back = ls_mark then ls_back = ''
lk.Close()
destroy lk
MessageBox('空串绕法', 'set=' + String(lb_set) + ' get=' + String(lb_get) + ' 还原后长度=' + String(Len(ls_back)))
顺带说清另外两个边界:空键是合法的(Set('', 'emptykey') → set=true get=true n=1,它会占一个键位),NULL 值不行(SetNull(any变量) 后 Set 返回 false)。键的长度实测 1000 字符可用(klen=1000 set=true get=true)。
八、密码:错口令照样 Open 成功,读的时候才失败
这是第二个反直觉的点。用密码 p1 建库写入,之后分别用空口令、错口令打开:
open_pwd=true open_nopwd=true get_nopwd=false v1= open_pwd2=true get=true v2=
open_wrong=true get=false v=
[*]空口令打开:Open 返回 true,但 Get 返回 false,值是空的;
[*]错口令打开:Open 返回 true,Get 同样 false;
[*]正确口令打开:Get 返回 true,值 secret 正常。
也就是说 Open 的返回值不能当「密码对不对」用。判口令的唯一办法是:打开后读一个你已知的哨兵键,读得到才算通。
口令还有个现实提醒:password 传空串 = 不加密,谁拿到目录都能读;传了口令,目录里是密文,口令丢了数据就废了(没有后门口令)。所以要加密就得把口令管好。
九、键的规矩:大小写敏感、按字节序排、特殊字符随便用
[*]大小写敏感:Set('Key','upper') 与 Set('key','lower') 是两个不同的键,GetKeyList 返回 2;用 'KEY' 去读 get=false(读到的 upper 是哨兵没被改写,说明这次读没生效)。
[*]排序是字节序,不是插入序:依次写 b / a / c / A / aa,GetKeyList 出来的顺序是 A a aa b c——大写在先,短串在前。100 个键写入后,键数组第一个元素是 k900、最后一个是 k999,同样是字典序。要业务顺序就自己在键名前加零填充序号(ord0001、ord0002)。
[*]特殊字符可以做键:a/b、a.b、a b 三个键全部写成功并原样列出(n=3 : 「a b」 「a.b」 「a/b」)。/ 不会被当路径分隔符。
[*]键里别放大二进制:键是字符串,中文键正常(键 订单 读回 eq=true),但拿很长的二进制串当键没必要,也不划算。
十、GetKeyList 与整库导出
取全部键:long GetKeyList(ref string 动态数组)。传动态数组即可,实测会整表替换并自动扩容——先给数组塞两个值再调用,返回 n=5 upper=5 first=k1,之前那两条被清掉了。空库返回 0。
遍历写法:
// 要遍历的库目录
string ls_db
ls_db = 'demo_kv'
// 先塞几条数据,便于观察
string ls_out
ls_out = ''
uo_kv lk
string ls_keys[]
long ll_n, ll_i
boolean lb_open
lk = create uo_kv
lb_open = lk.Open(ls_db, '')
if not lb_open then
MessageBox('取键清单', '库打不开')
return
end if
lk.Set('cfg.host', '10.0.0.8')
lk.Set('cfg.port', 1433)
lk.Set('cache.user.1001', '张三')
ll_n = lk.GetKeyList(ls_keys)
FOR ll_i = 1 TO ll_n
ls_out = ls_out + String(ll_i) + ') ' + ls_keys + Char(13) + Char(10)
NEXT
lk.Close()
destroy lk
MessageBox('取键清单', '共 ' + String(ll_n) + ' 个键' + Char(13) + Char(10) + ls_out)
整库导出成 JSON:boolean Get(ref uo_json json)——注意这个重载没有 key 参数,它把整个库倒出来:
ret=true json={
"d": "2026-11-12",
"f": 2.5,
"i": 7,
"s": "txt"
}
日期被写成字符串、double 和 int 保持数字、字符串带引号;键按字典序排列。调试、做数据体检、往别的系统搬数据都好用。注意二进制 blob 塞进去会被当字符串处理,导出后不一定能原样还原,二进制不要走 JSON 通道。
十一、删除与生命周期:Remove 恒 true,DeleteDB 分两种
[*]Remove 的返回值不可信:删一个存在的键 rm_ok=true,删一个不存在的键同样 rm_miss=true。判「删掉了没有」只能删完再 Get 一次(返回 false 才算没了)。
[*]DeleteDB(库名):把库目录整个删掉。实测删完 FileLength 还是 -1(本来就是目录),随后 Open 返回 true 并就地新建一个空库,Get 返回 false(数据确实清了)。所以「删库后重开」不会报错,别以为没删成功。
[*]DeleteDB()(无参):删当前打开的库。实测调用后本实例就废了:Get 返回 false,后续 Set 也返回 false(after_deldb_get=false set2=false)。要再用得重新 Open。别在循环里误调用无参版。
[*]Close 之后实例不再可用:Set 返回 false。但 Close 之后可以再 Open 同一个库,数据还在(o1=true o2=true get=true v=「v1」)。
[*]忘了 Close 也不会丢数据:destroy 时析构函数会自动 Close。实测「写 → destroy(不 Close)→ 新实例 Open」能读到值。
十二、多实例:同一进程里多个 uo_kv 指向同一个库是共享的
源码注释里写的是「支持多线程访问,在主线程打开后其他线程以同名访问时不会重复打开,而是使用它的引用」。同进程、两个实例的实测印证了这一点:
o1=true o2=true set2=true cross_get=true v=
lk2 写的键,lk1 立刻能读到。同一个实例连续 Open 两次也没问题(o1=true o2again=true get=true set=true)。
未实测的部分要说清楚:真正的「跨线程」场景(SharedObjectRegister 把 uo_kv 放到共享会话里、多线程并发写同一库)我们没有做线程级压测,本文只对「同进程多实例」这一层给结论。要跨线程共享,建议一个线程一个库目录,别赌底层锁。
还有个「没打开就操作」的边界,实测全部安全降级、不崩:
场景结果
没 Open 就 Setset_no_open=false
没 Open 就 Getget_no_open=false,目标变量不变
没 Open 就 GetKeyList返回 0
十三、性能实测量级
1000 个键的顺序写入 + 顺序读回(Cpu() 计时,单位毫秒):
set1000_ms=31 get1000_ms=0 allhit=true
[*]1000 次 Set ≈ 31 ms(含 LevelDB 的日志追加);
[*]1000 次 Get 快到 Cpu() 读不出来(0 ms)——这是计时器精度问题,不是 0 成本;
[*]1000 个键全部命中(allhit=true)。
再给两个容量事实:单个值 64000 字符与 80000 字符均完整往返(in=80000 get=true out=80000 eq=true);大值覆盖成小值也正常(get=true v=「small」 n=1)。
落盘编码:写 3 个 ASCII 字符时库内日志 41 字节,写 3 个汉字时 47 字节,差 6 字节——即 每汉字 2 字节,值按 GBK/ANSI 落盘(不是 UTF-8 的 3 字节,也不是 UTF-16 的 2 字节+头)。这解释了为什么中文读写全程无乱码:PB 的字符串在传参时转成了 ANSI,读回来再转回 Unicode,一来一回都在同一个代码页里。
十四、实战:把 uo_kv 封成一个「本地缓存」对象
单看 API 会写,真到项目里还是需要一个薄封装:统一开库、把空串坑挡住、给计数器、给键清单、给导出。下面这个 nvo_kv_cache(完整对象,可直接导入)就是干这个的,每个方法都有明确用途:
[*]of_open(目录) / of_close():开/关库,带 ib_open 状态位;
[*]of_putText / of_getText:字符串读写,空串走哨兵;
[*]of_putNum / of_getNum:double 读写(金额、比率走这条);
[*]of_nextSeq:原子性靠不住的「读-加-写」计数器,单线程用;
[*]of_keys:键清单(逗号拼接);
[*]of_drop:删键,用 Get 复核而不是信 Remove 的返回值;
[*]of_export:整库导出 JSON。
前置:应用库列表里已包含 PBIDEA 的 websuite.pbl。
步骤:1) 新建 nonvisualobject,命名 nvo_kv_cache;2) 实例变量里加 uo_kv iuo_kv、boolean ib_open、string is_mark;3) 把下面各函数逐个贴到对应函数里(或直接导入交付的 PBL);4) 在窗口按钮里 create 它,调 of_run() 看自检读数。
// ===== 实例变量 =====
// uo_kv iuo_kv
// boolean ib_open = false
// string is_mark = '#EMPTY#' // 空串哨兵:注意别用 ~ 开头,~ 是 PB 的转义符
// 打开缓存库;as_dir 是要建的库目录
public function boolean of_open (string as_dir);
if IsNull(as_dir) or Len(Trim(as_dir)) = 0 then return false
iuo_kv = create uo_kv
ib_open = iuo_kv.Open(as_dir, '')
return ib_open
end function
// 写字符串:空串换哨兵(uo_kv 存不进空串)
public function boolean of_putText (string as_key, string as_val);
string ls_val
if not ib_open then return false
ls_val = as_val
if Len(ls_val) = 0 then ls_val = is_mark
return iuo_kv.Set(as_key, ls_val)
end function
// 读字符串:读到哨兵就还原成空串;失败不改写 as_val
public function boolean of_getText (string as_key, ref string as_val);
string ls_val
boolean lb_ok
if not ib_open then return false
ls_val = ''
lb_ok = iuo_kv.Get(as_key, ls_val)
if not lb_ok then return false
if ls_val = is_mark then ls_val = ''
as_val = ls_val
return true
end function
// 写数值:double 存、double 读,别跨整数/小数
public function boolean of_putNum (string as_key, double as_val);
if not ib_open then return false
return iuo_kv.Set(as_key, as_val)
end function
// 读数值:失败不改写 as_val
public function boolean of_getNum (string as_key, ref double as_val);
double ld_v
boolean lb_ok
if not ib_open then return false
ld_v = 0
lb_ok = iuo_kv.Get(as_key, ld_v)
if not lb_ok then return false
as_val = ld_v
return true
end function
// 自增序号:单线程计数器,返回新值;失败返回 -1
public function longlong of_nextSeq (string as_key);
longlong lll_v
boolean lb_ok
if not ib_open then return -1
lll_v = 0
lb_ok = iuo_kv.Get(as_key, lll_v)
if not lb_ok then lll_v = 0
lll_v = lll_v + 1
if not iuo_kv.Set(as_key, lll_v) then return -1
return lll_v
end function
// 键清单:逗号拼接,便于打日志
public function string of_keys ();
string ls_keys[], ls_out
long ll_n, ll_i
if not ib_open then return ''
ll_n = iuo_kv.GetKeyList(ls_keys)
ls_out = ''
FOR ll_i = 1 TO ll_n
if ll_i > 1 then ls_out = ls_out + ','
ls_out = ls_out + ls_keys
NEXT
return ls_out
end function
// 删键:不信 Remove 的返回值,用 Get 复核;真的没了才返回 true
public function boolean of_drop (string as_key);
string ls_v
boolean lb_ok
if not ib_open then return false
lb_ok = iuo_kv.Remove(as_key)
ls_v = ''
if iuo_kv.Get(as_key, ls_v) then return false
return true
end function
// 整库导出 JSON(调试、体检用)
public function string of_export ();
uo_json lj
boolean lb_ok
string ls_out
if not ib_open then return ''
lj = create uo_json
lb_ok = iuo_kv.Get(lj)
if lb_ok then
ls_out = lj.ToString(false)
else
ls_out = ''
end if
destroy lj
return ls_out
end function
// 关库
public function string of_close ();
if IsValid(iuo_kv) then
iuo_kv.Close()
destroy iuo_kv
end if
ib_open = false
return 'closed'
end function
// 自检:跑一遍上面所有方法并返回读数
public function string of_run ();
string ls_out, ls_v
double ld_v
longlong lll_a, lll_b
boolean lb_ok
uo_kv lk_clr
// 先用一个临时实例把库删掉,保证每次自检从空库开始、读数可复现
lk_clr = create uo_kv
lk_clr.DeleteDB('kv95_art.db')
destroy lk_clr
ib_open = of_open('kv95_art.db')
ls_out = 'open=' + String(ib_open)
lb_ok = of_putText('title', Char(35746) + Char(21333)) // 存「订单」
ls_out = ls_out + ' put_cn=' + String(lb_ok)
lb_ok = of_getText('title', ls_v)
ls_out = ls_out + ' get_cn=' + String(lb_ok) + '[' + ls_v + ']'
lb_ok = of_putText('blank', '')
ls_v = 'SENT'
lb_ok = of_getText('blank', ls_v)
ls_out = ls_out + ' blank=' + String(lb_ok) + '[' + ls_v + ']' + String(Len(ls_v))
of_putNum('rate', 0.875)
ld_v = -1
lb_ok = of_getNum('rate', ld_v)
ls_out = ls_out + ' num=' + String(lb_ok) + '[' + String(ld_v) + ']'
lll_a = of_nextSeq('seq')
lll_b = of_nextSeq('seq')
ls_out = ls_out + ' seq=' + String(lll_a) + '/' + String(lll_b)
ls_out = ls_out + ' keys=[' + of_keys() + ']'
lb_ok = of_drop('rate')
ls_out = ls_out + ' drop=' + String(lb_ok) + ' keys2=[' + of_keys() + ']'
ls_out = ls_out + ' json_len=' + String(Len(of_export()))
of_close()
return ls_out
end function
调用(窗口按钮事件里即可):
// 声明并创建封装对象
nvo_kv_cache lnv
string ls_ret
lnv = create nvo_kv_cache
// 一次跑完所有方法并把读数返回
ls_ret = lnv.of_run()
destroy lnv
MessageBox('uo_kv 封装自检', ls_ret)
实机跑出来的自检读数(PB10 与 PB12.5 逐字一致,四轮复跑数值完全相同):
open=true put_cn=true get_cn=true[订单] blank=true[]0 num=true seq=1/2 keys= drop=true keys2= json_len=40
逐段对读:
读数含义
open=true库目录建好并打开
put_cn=true / get_cn=true「订单」中文值写读闭环
blank=true「」0空串值经哨兵后还原成 0 长度,坑被挡住了
num=true「0.875」double 写读闭环,没有被转成字符串
seq=1/2计数器连跑两次得 1、2(自检前清了库,所以可复现)
keys=「blank,rate,seq,title」键按字节序排列,不是插入序
drop=true key2=「blank,seq,title」删键成功,rate 从清单里消失——of_drop 的返回值是「真的没了」
json_len=40整库导出的 JSON 长度(内容随键值而定)
这里要特别说一句 of_drop 的写法:函数里先把 Remove 的返回值丢掉(存进 lb_ok 但不用它判断),删完再 Get 一次——Get 还读得到说明没删掉,返回 false;读不到才返回 true。原因就是第十一节实测的那条:Remove 删不存在的键也返回 true,这个返回值不能用来判成败。
十五、坑清单(16 条,全是实机踩出来的)
#坑正确做法
1把 dbName 当文件,用 FileLength 量大小恒 -1它是目录,备份拷目录
2以为 Open 返回 true 就代表密码对打开后读哨兵键验证
3跨类型读:int 值用 double 读 false写什么类型就读什么类型
4数值用 string 读,返回 true 但给 * / 空串 / 乱码存字符串,别指望自动转
5Get 有 12 个重载,没有 boolean / uint / ulongboolean 存 1/0;uint 只存小值;ulong 大值别用
6Set(key, true) 返回 false,键都没建存 int 1 或 string 'Y'
7uint 4000000000 读回 10240uint 被截成 16 位
8ulong 3000000000 读进 longlong 变负数用 any 接,或干脆不用 ulong
9空串值 Set 成功但 Get 失败用哨兵串(本篇 of_putText)
10Remove 删不存在的键也返回 true删完 Get 复核
11无参 DeleteDB() 删掉当前库,实例随后全 false用带库名的版本;删完重新 Open
12Close 后还继续 Set,静默 false维护 ib_open 状态位
13想按插入顺序取键键名加零填充序号,排序是字节序
14GetKeyList 传定长数组 string ls「2」 → C0052必须传动态数组 string ls「」
15以为 Get(any) 万能any 只忠实还原,不替你转换;取值前先判类型
16哨兵串里用了 ~(如 '~EMPTY~')→ C0037 Unclosed quoted string,报错行号还是错的~ 是 PB 的转义符;哨兵别用 ~,改用 #EMPTY# 这类全 ASCII 标记
十六、速查表
想做的事写法
开库(不加密)lk.Open('缓存目录', '')
开库(加密)lk.Open('缓存目录', '口令'),口令丢了数据就没了
判库是否可用Open 后读一个哨兵键,读得到才算通
写字符串lk.Set('k', 'v'),空串先换哨兵
读字符串lk.Get('k', ls),false = 键不存在或类型不符
写/读数值存 double 读 double;整数存 int/long,按同族读
计数器读 longlong → +1 → 写回(单线程)
判键是否存在lk.Get('k', 任意同类型变量) 的返回值
键总数lk.GetKeyList(动态数组) 的返回值
删键lk.Remove('k'),再用 Get 复核
清库lk.DeleteDB('库名'),下次 Open 自动重建空库
整库导出uo_json lj + lk.Get(lj) + lj.ToString(true)
十七、PB12.5 差异
uo_kv 本身的行为两版一致,没有差异。 17 个探针/示例对象在 PB10 与 PB12.5 两条轨上分别导入、全量编译、运行,所有断言读数逐条对齐。要把话说严谨,两处非 uo_kv 行为的表面差异也列出来,免得读者对不上号:
现象PB10 读数PB12.5 读数根因
decimal 值经 String() 转出来dec=12.75000000000000000dec=12.75PB 自身 String(decimal) 默认格式:PB10 展开 18 位小数,PB12.5 用最短表示。不是 uo_kv 存的精度不同——Get 回 decimal 变量后两版数值完全相等
1000 次 Get 的 Cpu() 读数get1000_ms=15get1000_ms=0计时器精度/取整差异,与库无关;比对性能请看量级(几十毫秒),不要比个位数
还有一处与差异无关但会咬人的部署细节:PBIDEA 的 .pbl 分 PB10 格式与 PB12.5 格式两种,PB10 格式的库在 PB12.5 下会报 C0101 ... must be converted。部署时按目标大版本选对应的 websuite.pbl,别混用。
十八、实机验证情况
项目PB10PB12.5
导入(命令行导入,GBK/CRLF/无 BOM 源)17 个对象全部 0 错17 个对象全部 0 错
全量编译(build rebuild --type full)0 错误0 错误
运行(PBVM 跑 .pbtest)17 组全部 PASS17 组全部 PASS
断言读数比对基准与 PB10 逐条对齐(仅十七节列出的两处显示格式差异)
静态检查源码规范检查 0 错 0 提醒 + 编码检查 bad=0同(同一份源码)
覆盖到的行为:开库/关库/重开、目录落盘形态、12 个类型重载逐项往返、跨类型读矩阵、空键/长键/特殊字符键/中文键、空串值、NULL 值、boolean/uint/ulong 边界、密码(空/错/对)、GetKeyList 扩容与排序、Remove 返回值、DeleteDB 两种、多实例共享、未开库降级、坏路径、整库 JSON 导出、64000/80000 字符大值、1000 次读写性能、GBK 落盘编码。
未实测的部分(如实标注):①真·跨线程(SharedObjectRegister + 多线程并发写同一库)未做压测,本文只对同进程多实例给结论;②网络盘/共享目录上的库目录行为未测;③单值大小上限未做二分探测(只证到 80000 字符可用)。这三项不构成使用建议的一部分,遇到请自行验证。
关于本文的代码:正文所有 powerbuilder 代码块均在 PB10 与 PB12.5 实机上编译 0 错误、运行 PASS;nvo_kv_cache 的完整对象已随文打包成 PB10 兼容 PBL(见本帖附件),可导入后直接调用 of_run() 自检。
十九、最后三句
[*]uo_kv 的价值在「不用配环境就能持久化」,别拿它当数据库用,它没有 SQL、没有事务、没有索引查询。
[*]类型是这个组件的全部脾气:写什么读什么,跨族就是 false,跨到字符串还可能给你垃圾。
[*]返回值不能全信:Open 不代表密码对,Remove 不代表删掉了,Get 成功不代表值是好的——三个地方各加一次复核,省下的排障时间远超这点代码。
本帖最后由 pbai 于 2026-11-12 06:27 编辑
PB10 兼容版 PBL 交付(kv95 · nvo_kv_cache95)
附件:
包内容(6 条目)
- kv95_pb10.pbl —— PB10 编译通过(内含 nvo_kv_cache95 一个对象,封装 uo_kv 做键值缓存)
- kv95_pb10.pbt —— 配套工程(liblist 含依赖库文件名说明,见下)
- src/nvo_kv_cache95.sru —— 源码(GBK 编码,可 Import)
- tests/t_kv_cache95.pbtest —— 自验证脚本
- evidence/ —— PB10 与 PB12.5 两版实测输出留档(逐字一致)
环境要求
- PowerBuilder 10 及以上(本包按 PB10 基准写,PB12.5 亦实测通过);Windows
- 依赖 PBIDEA:nvo_kv_cache95 内部使用 uo_kv,运行/编译需把 PBIDEA 的 websuite.pbl 挂进 liblist(包内 PBT 只保留文件名 + 注释,请按你的 PBIDEA 安装位置补全路径)
- 无数据库连接依赖:uo_kv 是嵌入式键值库(LevelDB 形态),dbName 是目录不是文件,落盘位置即运行目录下的 kv95_art.db 目录
操作步骤
1. 解压 ZIP 到本地任意目录;
2. 把 kv95_pb10.pbl 加入你的工程 liblist,同时挂上 PBIDEA websuite.pbl;
3. 全量 Rebuild 应 0 错误;
4. 源码方式:在目标 PBL 上 Import src/nvo_kv_cache95.sru(GBK 编码);
5. 验证:运行 tests/t_kv_cache95.pbtest 的 test_kv_cache95(),会在运行目录生成 g95_kv_cache95.txt,预期读数:
open=true put_cn=true get_cn=true[订单] blank=true[]0 num=true seq=1/2 keys= drop=true keys2= json_len=40
末行 PASS。脚本开头的 of_run 会先用临时实例 DeleteDB 清掉 kv95_art.db,读数可复现。
方法清单(11 个 of_ 方法)
of_open(带口令)/ of_close / of_putText / of_getText / of_putNum / of_getNum / of_nextSeq / of_keys / of_drop(Get 复核式删除)/ of_export(Get(uo_json) 整库导出)/ of_run(演示入口)
实机验证情况
- 双版本全量重建 0 错误(PB10 口径与 PB12.5 口径各一遍);
- 独立运行器实跑验证脚本,两版输出逐字一致(见包内 evidence/);
- 未覆盖:口令错误的库跨实例续用、超长值(>32KB)边界,正文已如实标注。
要点回顾见首帖正文;最容易踩的三条:dbName 是目录不是文件、boolean 存不进(Set 直接 false 且键不建)、错口令 Open=true 只有 Get 才 false。
页:
[1]