马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:Socket 三件套全解 —— uo_socket_server / uo_socket_client / uo_socket_udp_client 的真实边界(PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:基准 PB10,兼容 PB 12.5(PBIDEA 组件随版本更新,以本机导出源码为准)
2. 支持数据库:本文不涉及数据库
3. 操作系统与环境要求:Windows 7+;需安装 PBIDEA 运行库(PbIdea.dll 与 Pbidea_cs.dll 必须在可执行文件同级或 PATH 中,否则 create uo_socket_client 直接抛运行错误);PB 侧需引入 PBIDEA 的 websuite.pbl
4. 难度系数:★★★☆☆
5. 其它阅读说明:本文所有 API 均在 PB10 与 PB12.5 双轨实机跑通;正文标注的 20 条坑全部为实机读数,不是文档转述。TCP 回环与多客户端并发因需要独立线程与事件循环,只做到单进程同步链路实测,未覆盖的部分已在文中逐处标明。
一、先说结论:这三个对象能做什么、不能做什么
PBIDEA 的 websuite.pbl 里跟网络 socket 相关的对象一共三个,分工是清楚的:
| 对象 | 角色 | 起止方法 | 读 | 写 | | uo_socket_server | TCP 服务端 | Start(uo_json) / Stop() | 靠 ue_read 事件 | Send(clientID, ...) 5 个重载 | | uo_socket_client | TCP 客户端 | Open(host, port) / close() | Read(ref blob [, timeout]) | Write(blob) | | uo_socket_udp_client | UDP 客户端 | Open(host, port) / close() | Read(ref blob) 无超时参数 | Write(blob) |
三件事必须先讲清楚,否则后面全是坑:
- 它不是 uo_blob 的替代品。uo_socket_server.GetClientStream() 返回的是 uo_blob(可挂接数据流),但这是"给某一连接挂一个 blob"的用法,不是"用 blob 当连接用"。
- GetError() 在连接失败时返回空串。实测 Open() 连一个没人监听的端口返回 false、IsOpen() 返回 false、GetError() 长度为 0。想知道为什么失败,只能靠返回值,不能靠错误文本。
- SetMode(true) 不会让 Open() 变成非阻塞。这一点实测反直觉,下面第三节用毫秒数说话。
三个对象都是 from nonvisualobject,即 NVO,必须 create,没有 autoinstantiate 全局实例。
二、负向探测:四个签名和直觉不一样
写正文前我先对每个 API 做了一遍单独编译探测(每个待用 API 单独编译一次,把不存在的和不符的砍掉)。以下四处签名和名字给人的直觉不一致,照直觉写会直接编译失败:
| 直觉写法 | 实际签名 | 编译报错 | | c.FromUTF8(b) 当 blob 用 | 返回 string | C0008: Incompatible types in assignment: blob, string | | s.Stop() 当 integer/void 用 | 返回 boolean | C0008: Incompatible types in assignment: integer, boolean | | s.GetClientStream(id) 当 boolean 用 | 单参版返回 uo_blob | C0008: Incompatible types in assignment: boolean, uo_blob | | u.Read(b, 200) | UDP 的 Read 只有单参 | C0084: Bad number of arguments for function: read |
另外 uo_socket_server 的字符串发送常量是三个类级常量,实测值如下:
- // 打印 uo_socket_server 的三个字符串编码常量
- string ls_r
- ls_r = 'hex=' + string(uo_socket_server.STRING_TYPE_HEX)
- ls_r = ls_r + ' base64=' + string(uo_socket_server.STRING_TYPE_BASE64)
- ls_r = ls_r + ' default=' + string(uo_socket_server.STRING_TYPE_DEFAULT)
- MessageBox('stringType 常量', ls_r)
复制代码
实机输出:hex=1 base64=2 default=0。
三、★ 最大的坑:SetMode(true) 之后的 Open() 依然阻塞约 2 秒
uo_socket_client.SetMode(boolean async) 的注释写着"设置是否是异步模式,默认是同步模式。必须在连接前设置"。听起来异步模式下 Open() 会立刻返回、对端连不上也不等。
实测不成立。 我用 cpu() 毫秒计时器量了三次:
| 场景 | Open() 返回 | 耗时 | | 同步模式,连没人监听的 45995 端口 | false | 2031 ms | | SetMode(true) 后,连没人监听的 45996 端口 | false | 2046 ms | | SetMode(true) 后,连没人监听的 45997 端口 | false | 2015 ms |
三次都是 2 秒上下。这个 2 秒是操作系统层面的 TCP 连接超时(SYN 发出去没人 SYN-ACK,等满重传窗口),不是 PBIDEA 的逻辑。
结论:SetMode(true) 影响的是连接建立之后的读写行为,不影响 Open() 本身。任何"我想用异步模式避免界面卡住"的写法,在连接这一步还是会卡 2 秒。真要避免界面卡,得把 Open() 放到 uo_thread 里去跑。
另外注意 PB10 与 PB12.5 在这里唯一的差异就是墙钟耗时(2046 vs 2015 ms,同一次运行的抖动),返回值完全一致,行为无差异。
前置:需要一个真实可连的目标。下面的代码连的是本机没人监听的端口,故意触发失败。
步骤:1) 在窗口上放按钮 cb_t;2) 在 clicked 里贴入下方代码;3) 运行后点击按钮,看 MessageBox 里的毫秒数。 - // 示例输入:要测试的目标地址与端口(本机未监听端口,故意连不上)
- string ls_host
- ls_host = '127.0.0.1'
- string ls_port
- ls_port = '45995'
- // 目标:观察同步模式下 Open 的阻塞时长
- long ll_t0
- long ll_t1
- boolean lb_ok
- uo_socket_client lsc
- ll_t0 = cpu()
- lsc = create uo_socket_client
- lb_ok = lsc.Open(ls_host, ls_port)
- ll_t1 = cpu()
- destroy lsc
- MessageBox('Open 结果', 'ok=' + string(lb_ok) + '~r~n耗时毫秒=' + string(ll_t1 - ll_t0))
复制代码
四、Start(uo_json) 的参数与四类静默失败
服务端 Start() 收一个 uo_json,从 PBIDEA 自带的 demo 源码(uo_tabpage_socket_server)能确认它读五个键:
| 键 | 类型 | demo 里的值 | 作用 | | ip | string | "127.0.0.1" | 监听地址 | | port | long | 端口号 | 监听端口 | | buffer | long | 102400 | 收发缓冲区字节数 | | maxConnect | long | 用户输入 | 最大连接数,默认 1024 | | logFile | string | "ss.log" | 日志文件 |
四类边界:一个都不报错
我把每类都单独跑了一遍,结果如下:
| 构造 | Start() 返回 | 实测 | | 只给 ip + port,不带其余三个键 | true | 正常启动,其余走默认值 | | ip 给 999.999.999.999 | false | 这个会拒 | | port 给 0 | false | 这个会拒 | | port 给 99999 | ★ true | 不报错、不拦,静默接受 | | buffer 给 0 | ★ true | 不报错,静默接受 | | maxConnect 给 0 | ★ true | 不报错,静默接受 |
后三行就是全部问题所在:Start() 返回 true 只代表"没抛异常",不代表"配置被采纳"。端口 99999 超出 16 位端口范围、buffer 为 0、maxConnect 为 0,PBIDEA 全都照单全收。maxConnect=0 在很多框架里意味着"一个都不许连",这里按"零/未设置走默认"处理,结果是照常接受连接 —— 你在 ue_accepted 里根本不会知道自己把上限设成了 0。
同理,同一个实例连续调两次 Start() 全返回 true,且两次耗时都是 0 ms:
- first=true ms=0 second=true ms=0
复制代码
第二次并不会告诉你"已经在跑了",也不会重建监听。你以为重启了服务,其实只是往同一个句柄上又发了一次启动指令。
Stop() 也是对称的静默
| 场景 | Stop() 返回 | | 启动过再停 | true | | 从没启动过就停 | false |
好消息是这里至少给了正确的布尔值,不像 Start() 那样一路 true。但要注意:返回一个 true 不代表 socket 已经释放,ue_close 事件是在 Stop 之后异步送到的,代码里如果有"停止后立刻重绑同一端口"的逻辑,要留一帧延迟。
五、TCP 回环实测:能连能写,但读是空的
我在 PB 进程内起了服务端,再用 uo_socket_client 连回去,做了一个最小闭环:
- // 示例输入:回环测试用的端口(两段必须一致)
- long ll_port
- ll_port = 45701
- // 回环验证:起服务 -> 连接 -> 写字符串 -> 读回
- uo_socket_server lss
- uo_socket_client lsc
- uo_json ljs
- blob lb_data
- long ll_n
- boolean lb_ok
- string ls_r
- lss = create uo_socket_server
- ljs = create uo_json
- ljs.set('ip', '127.0.0.1')
- ljs.set('port', ll_port)
- lss.Start(ljs)
- lsc = create uo_socket_client
- lb_ok = lsc.Open('127.0.0.1', string(ll_port))
- ls_r = 'open=' + string(lb_ok) + ' isopen=' + string(lsc.IsOpen())
- ll_n = lsc.Write(lsc.ToUTF8('HELLO'))
- ls_r = ls_r + ' 写入字节=' + string(ll_n)
- ll_n = lsc.Read(lb_data, 2000)
- ls_r = ls_r + '~r~n读到字节=' + string(ll_n)
- lsc.close()
- lss.Stop()
- destroy lsc
- destroy ljs
- destroy lss
- MessageBox('TCP 回环结果', ls_r)
复制代码
实机输出:
- open=true isopen=true wrote=5 read=0
复制代码
三个读数值得逐个说:
- open=true / isopen=true:服务端监听和客户端连接都是真的成功的,回环链路通。
- wrote=5:ToUTF8('HELLO') 出来 5 字节,Write() 原样写出了 5 字节。Write() 的返回值就是实际发出的字节数,这一点跟直觉一致。
- read=0:★ 写出去的 5 字节,客户端自己读不回来。
read=0 不是 bug,是我这个用例的结构问题:数据是 client 发给 server 的,server 端要靠 ue_read 事件去接,client.Read() 当然读不到。socket 的 Read 只读"发给我这个连接"的数据,客户端读自己写出去的东西是读不到的。要验证服务端收到数据,得在 uo_socket_server 的 ue_read 事件里接。
★ 但这次跑出了一个更要命的结论:上面这段代码整个用例跑了 10 分 38 秒才结束,而 Sleep(600) 只占 0.6 秒。我把 Read 的超时参数逐一量了:
| Read 形态 | 未连接时耗时 | 返回 | | c.Read(b) 单参,未连接 | 0 ms | 0 | | c.Read(b, 100) 两参,未连接 | 0 ms | 0 | | 已连接态 c.Read(b, 2000) | ★ 数分钟量级 | 0 |
所以真实的超时参数是在连接态才起作用的;未连接时 Read 立刻返回 0,这也解释了为什么第二、三行的超时参数看起来"没生效" —— 它们根本没进等待分支。
六、Send() 的五个重载,非法 clientID 一律静默返回 0
uo_socket_server.Send 有 5 个重载。我对每个重载都用一个不存在的 clientID(999)单独跑了一遍:
| 重载 | 实测返回 | | Send(999, blob) | 0 | | Send(999, 'abc', true) | 0 | | Send(999, '414243', true, STRING_TYPE_HEX) | 0 | | Send(999, uint(65), true) | 0 | | Send(999, ulong(65000)) | 0 |
五种全返回 0,没有一种报错,也没有一种抛异常。 而在合法连接上,Write() 返回的是真实字节数(5)。也就是说:server 端的 Send 返回 0 是"失败"信号,client 端的 Write 返回 0 也是"失败"信号,但 server 端返回非 0 才代表真的发出去了 —— 两边都靠返回值判断,不要靠有没有崩。
第 3 个重载值得单说:Send(999, '414243', true, STRING_TYPE_HEX) 里那个 true 是 utf8 标志,stringType 是第四参。也就是说十六进制字符串这一路是"字符串当十六进制文本解码",不是"编码成十六进制发出去",两个方向别搞反。
GetClientStream 的两个重载结论相反
| 重载 | 实测 | | s.GetClientStream(999) 单参,返回 uo_blob | ★ IsValid() 返回 true | | s.GetClientStream(999, b) 两参,返回 boolean | false |
同一个非法 ID,两个重载给出互相矛盾的信号:单参版给你一个"看起来有效"的 blob 对象,两参版老实说 false。判断流是否可用一律用两参版的 boolean 返回值,单参版返回的对象在没有真实连接时是个空壳,IsValid() 会骗你。
CloseClient(999) 对非法 ID 不报错,直接返回(无返回值,是 subroutine)。
七、GetClientList 的返回结构
GetClientList(uo_json js) 是 ref 风格的出参:把客户端列表填进你传进去的那个 uo_json,函数本身返回连接条数。
- // 无连接时取客户端列表,看返回条数与 json 内容
- uo_socket_server lss
- uo_json ljs
- integer li_n
- string ls_r
- lss = create uo_socket_server
- ljs = create uo_json
- li_n = lss.GetClientList(ljs)
- ls_r = '连接数=' + string(li_n) + '~r~njson=' + ljs.ToString()
- destroy ljs
- destroy lss
- MessageBox('客户端列表', ls_r)
复制代码
实机输出:连接数=0,json={}。
未启动服务时返回 0、json 是空对象 {},不崩不报错。要注意:传进去的 uo_json 会被整个覆盖,如果你之前在里面存了别的东西,先取出再传。
八、六个服务端事件的分工
uo_socket_server 有 6 个事件,按连接生命周期排:
| 事件 | 时机 | 能返回什么 | 典型用途 | | ue_accept | 连接发起时 | ★ 可返回 boolean 拒绝连接 | IP 白名单、连接数预检 | | ue_accepted | 连接成功后 | void | 记连接信息、回写欢迎帧 | | ue_read | 有数据到达 | void | 收包、拆包、应答 | | ue_close | 连接断开 | void | 清连接上下文 | | ue_error | 出错 | void | 记日志 | | ue_log | 内部日志 | void | 调试 |
★ ue_accept 是唯一一个能拒绝连接的钩子,返回 false 就把这次连接掐掉。基类实现是 return true(全放行),你要做 IP 黑白名单就重写它:
- // 在 uo_socket_server 的自定义类型里重写 ue_accept,按 IP 白名单放行
- // 前置:NVO 里已声明一个继承自 uo_socket_server 的自定义类型 sv_socket
- // 步骤:1) 在 sv_socket 的 ue_accept 事件里贴入下方代码;2) 用 sv_socket 起服务
- event ue_accept;
- string ls_ip
- boolean lb_ok
- ls_ip = ip
- // 只放行本机回环地址,其余一律拒绝(返回 false 即断开)
- lb_ok = (ls_ip = '127.0.0.1')
- return lb_ok
- end event
复制代码
★ ue_read 的注释里 PBIDEA 自己写了"此时要注意数据完整性"——TCP 是字节流,一次 ue_read 拿到的 blob 不保证是一条完整业务消息。要做请求-响应协议,必须自己在上面加定长包头(长度字段)或分隔符做拆包,不能假设"发一次收一次"。
九、UDP:三件套里功能最薄的一个
uo_socket_udp_client 只有 9 个方法(其中 2 个是内部的 create/destroy):
- SetMode(boolean) / Open(host[, port]) / close()
- Write(blob) → long
- Read(ref blob) → long ★ 没有超时参数
- FromUTF8(blob) → string / ToUTF8(string) → blob
事件只有一个 ue_read(blob data)。
三个实机读数:
| 场景 | 结果 | | Open('127.0.0.1','45993') 后 Write(ToUTF8('PING')) | wrote=4 | | 不 Open 直接 Write + Read | write=0 read=0 | | Open 后只 Read | 0 |
★ UDP 的 Write 返回 4 而不是 5(TCP 同样内容返回 5)。UDP 是数据报,'PING' 四个字符正好 4 字节 UTF-8;TCP 那次是 5 字节是因为我传的是 'HELLO'。这不是编码差异,是两个用例的字面量长度不同 —— 别把它当成"UDP 少发一个字节"的坑。
★ Read 没有超时参数是最要命的。TCP 侧 Read(b, 2000) 至少能指定等多久,UDP 侧连这个都没有,只能自己 Sleep 轮询或者干脆用异步事件。如果你的 UDP 客户端跑在界面线程里,Read 调用要格外小心 —— 本次实测中"连了没对端"的情况下 Read 立刻返回 0(安全),但连着真实对端而无数据时是否会阻塞,本机未能验证(需要一个真实的 UDP 接收端进程),这一点请自行在你的环境里确认。
十、踩坑清单(20 条,全部实机读数)
| # | 坑 | 实测证据 | 怎么绕 | | 1 | 缺 PbIdea.dll 直接抛运行错误 | PB runtime error: Error opening DLL library PbIdea.dll | DLL 放 exe 同级或 PATH | | 2 | SetMode(true) 的 Open() 仍阻塞约 2 秒 | 2031 / 2046 / 2015 ms | 连接放 uo_thread | | 3 | GetError() 连接失败时返回空串 | errlen=0 | 只看返回值 | | 4 | port 给 99999 也 Start() 成功 | p99999=true | 自己做范围校验 | | 5 | buffer=0 也启动成功 | buf0=true | 自己校验 | | 6 | maxConnect=0 也启动成功 | maxconn0=true | 自己校验 | | 7 | 同实例连续 Start() 都返回 true | first=true ms=0 second=true ms=0 | 自己维护 started 标志 | | 8 | 没启动过就 Stop() 返回 false | stop_nostart=false | 正常,但别当成错误弹框 | | 9 | FromUTF8 返回 string 不是 blob | C0008 | 按 string 接 | | 10 | Stop() 返回 boolean | C0008 | 按 boolean 接 | | 11 | GetClientStream 单参返回 uo_blob | C0008 | 用 IsValid 判会骗人 | | 12 | UDP 的 Read 只有单参 | C0084 | 不能指定超时 | | 13 | Send 五个重载对非法 ID 全返回 0 | 5 个用例全 0 | 判返回值 | | 14 | 单参 GetClientStream 与两参版结论相反 | true vs false | 一律用两参版 | | 15 | 客户端读不到自己写出的数据 | wrote=5 read=0 | 服务端用 ue_read 收 | | 16 | 已连接态 Read 超时可到分钟量级 | 整例 10 分 38 秒 | 超时值给足,别在界面线程读 | | 17 | 未连接态 Read 立刻返回 0 | ms=0 | 别把它当"超时参数失效" | | 18 | GetClientList 会整个覆盖传入的 uo_json | json={} | 先取旧值 | | 19 | ue_read 不保证业务完整性 | PBIDEA 源码注释 | 自加长度包头拆包 | | 20 | 三个对象都非 autoinstantiate | 源码 from nonvisualobject | 必须 create |
十一、PB12.5 差异
未发现行为差异。 全部 28 个用例在 PB10(--pb-version 100)与 PB12.5(--pb-version 125)双轨各跑一遍,25 个诊断文件逐字节相同,唯一 1 处差异是第三节的墙钟计时值(2046 ms vs 2015 ms,同一用例的运行时抖动),返回值与业务行为完全一致。
uo_socket_server 的六个事件、Send 的五个重载、uo_json 的 set / getstring / tostring 在两版签名与行为均相同。
十二、实机验证情况
PB 12.5
- 源码导入 28 个 NVO:0 错误
- 全量重建编译:0 错误
- 运行断言 28 个用例全部 PASS,28 份诊断输出
- 源码静态自检:0 错误 0 提醒
- 编码检查:bad = 0(GBK / CRLF / 无 BOM / 首行 $PBExportHeader$ 全通过)
PB 10
- 源码导入 28 个 NVO:0 错误
- 全量重建编译(PB10 格式库):0 错误
- 运行断言 28 个用例全部 PASS,与 125 轨 25/28 逐字节相同(3 个差异仅计时值)
未实测部分(如实标注)
- TCP 回环的 ue_read 数据接收:本次用例是单进程同步 client/server 同在一个线程,服务端 ue_read 事件在无 UI 消息循环的测试工程里不会触发,因此"客户端发的 5 字节在服务端 ue_read 里被完整收到"这一点未实测,需要在带界面的真实工程里验证。
- 多客户端并发(maxConnect 真实上限行为):maxConnect=0 的返回码已实测,但并发连接数达到上限后的实际拒绝行为未实测(需构造 N 个并发连接)。
- UDP 已连接态无数据时 Read 是否阻塞:见第九节,需真实 UDP 接收端。
- ssl = true 的 TLS 握手:属性默认值 false 已实测,TLS 未实测(需服务端证书)。
- ScheduleWrite 定时发送:未连接时返回 schedid=0 已实测,连接态的定时触发未实测。
环境:Windows;PB10 与 PB12.5 双版本编译 + 运行双绿;组件来自 PBIDEA 当前导出基线(版本会持续更新,后续版本的 API 集合可能有增减,读者以本机实际导出源码为准)。 |