马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
本帖最后由 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)。这张表就是它的全部:
| 成员 | 签名 | 说明 | | Open | boolean Open(string dbName, string password) | 打开库。dbName 是目录名;password 为空表示不加密 | | Close | subroutine Close() | 关闭库 | | DeleteDB | subroutine DeleteDB() | 删除当前打开的库(调用后本实例报废) | | DeleteDB | subroutine DeleteDB(string dbName) | 按名字删库(可删别的库) | | Set | boolean Set(string key, any value) | 写键值,值走 any | | Get | boolean Get(string key, ref <类型> value) | 按类型取值,共 12 个重载 | | Get | boolean Get(ref uo_json json) | 整库导出成 JSON 对象 | | GetKeyList | long GetKeyList(ref string 动态数组) | 取全部键,返回键个数 | | Remove | boolean 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=[vv]
复制代码
库不仅建成了,第二次打开还能读到上次写的值,但 FileLength 永远是 -1——因为 demo_kv 是个目录。进去看结构:
| 文件 | 作用 | | CURRENT | 指向当前 MANIFEST | | MANIFEST-000001 | 版本清单 | | 000003.log | 写入日志(数据主要躺在这里,直到压实) | | LOCK | 库锁文件 | | LOG / LOG.old | LevelDB 自己的运行日志 |
由此得到几条实用结论:
- 备份/迁移 = 拷整个目录,拷单个文件没有意义;
- 不要往库目录里放自己的文件,也不要手工改里面的东西;
- 目录不存在时 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 按你给的目标变量类型去匹配标签。匹配规则不是「能转就转」,而是「同族才转」。
先看正路(同类型往返,全部通过):
| 存的类型 | 写法 | 读回同类型 | 实测读数 | | string | Set('s','hello') | Get('s', ls) | s=「hello」true | | int | Set('i', 7) | Get('i', li) | i=7true | | long | Set('l', 123456) | Get('l', ll) | l=123456true | | double | Set('d', 3.5) | Get('d', ld) | d=3.5true | | decimal | Set('dec', ldec) | Get('dec', ldec2) | dec=12.75true | | real | Set('real', lr) | Get('real', lr2) | real=1.5true | | longlong | Set('ll', 9000000000) | Get('ll', lll) | ll=9000000000true | | date | Set('d', Date(2026,11,12)) | Get('d', ldt) | d=2026-11-12true | | time | Set('t', Time('08:30:15')) | Get('t', ltm) | t=08:30:15true | | datetime | Set('dt', DateTime(...)) | Get('dt', ldtm) | dt=2026-11-12 08:30:15true | | blob | Set('bin', Blob('AB')) | Get('bin', lb) | inlen=4 get=true outlen=4 eq=true |
跨类型读的实测矩阵(这张表比上面那张重要):
| 存 | 读成 | 结果 | 读数 | | int 42 | long | ✅ 通 | i2long=true「42」 | | int 42 | double | ❌ false | i2dbl=false「-1」 | | double 3.75 | int | ❌ false | d2int=false「-1」 | | double 3.75 | real | ✅ 通 | d2real=3.75 | | decimal 12.75 | double | ✅ 通 | dec2dbl=true「12.75」 | | double 12.75 | decimal | ✅ 通 | dbl2dec=true「12.75」 | | string '42' | longlong / long / int | ❌ 全 false | s2ll=false「-1」 s2long=false「-1」 s2int=false「-1」 | | string '7' | int | ❌ false | str2int=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[S]
复制代码
- 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[2026-11-12] dbl=true[2.5] str=true[txt] int=true[7]
复制代码
所以「类型不确定、就想原样拿出来看看」的场景,用 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[10240] u_ll=10240
复制代码
写入 uint 4000000000(0xEE6B2800),用 any 读回来是 10240(0x2800)——只剩低 16 位。这不是读的时候截的,是存的时候就没存全。uint 只适合 0~65535 的小值。
③ ulong 能存住,但读进 longlong 会变负
- ul_any=true[3000000000] ul_ll=-1294967296
复制代码
ulong 3000000000 用 any 读回正确(3000000000),但用 longlong 读回是 -1294967296(按 32 位有符号解释)。所以 ulong 值要么用 any 接,要么读进 ulong——而 ulong 没有重载(C0082)。结论:ulong 超过 2^31 时不要用 uo_kv 存。
④ 空字符串值存得进、读不回
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=[SENT] open_pwd2=true get=true v2=[secret]
- open_wrong=true get=false v=[SENT]
复制代码
- 空口令打开: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[ll_i] + 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=[from2]
复制代码
lk2 写的键,lk1 立刻能读到。同一个实例连续 Open 两次也没问题(o1=true o2again=true get=true set=true)。
未实测的部分要说清楚:真正的「跨线程」场景(SharedObjectRegister 把 uo_kv 放到共享会话里、多线程并发写同一库)我们没有做线程级压测,本文只对「同进程多实例」这一层给结论。要跨线程共享,建议一个线程一个库目录,别赌底层锁。
还有个「没打开就操作」的边界,实测全部安全降级、不崩:
| 场景 | 结果 | | 没 Open 就 Set | set_no_open=false | | 没 Open 就 Get | get_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[ll_i]
- 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[0.875] seq=1/2 keys=[blank,rate,seq,title] drop=true keys2=[blank,seq,title] 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 但给 * / 空串 / 乱码 | 存字符串,别指望自动转 | | 5 | Get 有 12 个重载,没有 boolean / uint / ulong | boolean 存 1/0;uint 只存小值;ulong 大值别用 | | 6 | Set(key, true) 返回 false,键都没建 | 存 int 1 或 string 'Y' | | 7 | uint 4000000000 读回 10240 | uint 被截成 16 位 | | 8 | ulong 3000000000 读进 longlong 变负数 | 用 any 接,或干脆不用 ulong | | 9 | 空串值 Set 成功但 Get 失败 | 用哨兵串(本篇 of_putText) | | 10 | Remove 删不存在的键也返回 true | 删完 Get 复核 | | 11 | 无参 DeleteDB() 删掉当前库,实例随后全 false | 用带库名的版本;删完重新 Open | | 12 | Close 后还继续 Set,静默 false | 维护 ib_open 状态位 | | 13 | 想按插入顺序取键 | 键名加零填充序号,排序是字节序 | | 14 | GetKeyList 传定长数组 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.75000000000000000 | dec=12.75 | PB 自身 String(decimal) 默认格式:PB10 展开 18 位小数,PB12.5 用最短表示。不是 uo_kv 存的精度不同——Get 回 decimal 变量后两版数值完全相等 | | 1000 次 Get 的 Cpu() 读数 | get1000_ms=15 | get1000_ms=0 | 计时器精度/取整差异,与库无关;比对性能请看量级(几十毫秒),不要比个位数 |
还有一处与差异无关但会咬人的部署细节:PBIDEA 的 .pbl 分 PB10 格式与 PB12.5 格式两种,PB10 格式的库在 PB12.5 下会报 C0101 ... must be converted。部署时按目标大版本选对应的 websuite.pbl,别混用。
十八、实机验证情况
| 项目 | PB10 | PB12.5 | | 导入(命令行导入,GBK/CRLF/无 BOM 源) | 17 个对象全部 0 错 | 17 个对象全部 0 错 | | 全量编译(build rebuild --type full) | 0 错误 | 0 错误 | | 运行(PBVM 跑 .pbtest) | 17 组全部 PASS | 17 组全部 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 成功不代表值是好的——三个地方各加一次复核,省下的排障时间远超这点代码。
|