马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:用 uo_websocket_client / uo_websocket_server 做 WebSocket 通信全解
阅读说明
1. 适用版本:基准 PB10,兼容 PB 12.5(PBIDEA 1.x,websuite.pbl;本文两版实测行为一致)
2. 支持数据库:本文不依赖数据库(服务端 InitVar 的本地变量库为可选功能,文中未实测)
3. 操作系统与环境要求:Windows 7+,需安装 PbIdea.dll 运行库(连同 Pbidea_cs.dll),应用程序库列表中须挂 websuite.pbl
4. 难度系数:★★★☆☆
5. 其它阅读说明:需要基本的 NVO 使用经验;服务端部分涉及窗口 Other 事件转发,建议先读过本目录 Socket 三件套一文
一、WebSocket 与 PB:为什么值得专门讲一篇
HTTP 是「一问一答」,客户端不发请求,服务器就永远没机会说话。WebSocket 把这条规矩改了:连接建立之后,双方随时都能往对方推消息,实时聊天、行情推送、扫码登录、设备上报,靠的都是它。
PBIDEA 在 websuite.pbl 里给了两个现成对象:uo_websocket_client 负责把 PB 应用变成 WebSocket 客户端,去连别人的服务;uo_websocket_server 更狠一步,直接把 PB 应用变成 WebSocket 与 HTTP 双模服务端,别的机器拿浏览器或者任何语言写的客户端都能连上来。
这两个对象此前没有文章系统写过。本文所有客户端行为、服务端基础 API 都在本机做了双轨实测(PB10 编译级与运行级、PB12.5 编译级与运行级,两轨全绿),服务端 Start 的运行期行为因依赖窗口消息泵无法在测试机架里安全验证,文中明确标注了实测边界,不冒充。
二、客户端全貌:7 个事件、9 组方法
uo_websocket_client 继承自 nonvisualobject,构造时自动调 wsCreate、析构时自动调 wsDestroy,create 之后直接用,不需要任何初始化调用。先看事件:
| 事件 | 参数 | 触发时机 | | onmessage | readonly string as_msg | 收到服务端消息,as_msg 是文本内容 | | onconnect | 无 | 连接建立成功 | | ondisconnect | 无 | 连接断开 | | onfail | readonly string as_msg | 连接或通信失败,as_msg 带失败说明 | | onheartbeat | 无 | 心跳检测失败(连不上对端时触发) | | onping | readonly string as_msg | 收到对端 ping | | onpong | readonly string as_msg | 收到对端 pong |
再看方法。源码里的 type prototypes 段一共 9 组签名,逐条列出:
| 方法 | 签名要点 | 说明 | | setProxy | boolean setProxy(readonly string proxy) | 设置代理,写法如 http://127.0.0.1:8888,抓包调试用 | | setHeader | boolean setHeader(readonly string key, readonly string value) | 连接前追加自定义请求头 | | ConnectServer | 三个重载 | (url, memo)/(url, memo, 心跳毫秒)/(url, memo, 心跳毫秒, autoReconnect) | | DisconnectServer | 两个重载 | DisconnectServer() 与 DisconnectServer(boolean triggerOnClose) | | SendMessage | 三个重载 | 发 blob、发 string、发 string 加 toUtf8 开关 | | GetStatus | int GetStatus() | 返回 0 连接中、1 已打开、2 关闭中、3 已关闭 | | ToUTF8 | blob ToUTF8(readonly string str) | 字符串转 UTF-8 字节 | | FromUTF8 | string FromUTF8(readonly blob utf8Data) | UTF-8 字节转回字符串 |
注意 memo 这个参数:它是本端自述标识,随连接送给服务端,官方示例里用它区分 client-1、client-2 这样的多连接。
三、客户端最小可运行示例(实测通过)
下面这段是连接、等握手、发消息的完整骨架,逻辑与本机实跑代码逐字一致(实测环境里用文件输出代替 MessageBox):
- // 输入值:前段定义并注释
- string ls_url // WebSocket 服务地址
- string ls_memo // 本端自述标识
- long ll_beat // 心跳间隔(毫秒)
- int li_k // 轮询计数器
- ls_url = 'ws://127.0.0.1:9911/'
- ls_memo = 'demo67'
- ll_beat = 3000
- uo_websocket_client lws
- int li_status
- boolean lb_ok
- lws = create uo_websocket_client
- // 带路径的 WebSocket 服务通常校验 Origin 头,按服务端要求决定是否设置
- lws.setHeader('Origin', 'http://localhost')
- lb_ok = lws.ConnectServer(ls_url, ls_memo, ll_beat)
- // ConnectServer 是异步的:返回 true 只代表请求已受理,不代表已连上
- li_k = 0
- do while li_k < 100
- Yield()
- if lws.GetStatus() = 1 then exit
- li_k = li_k + 1
- loop
- li_status = lws.GetStatus()
- if li_status = 1 then
- lws.SendMessage('hello websocket')
- end if
- MessageBox('客户端', 'connect=' + String(lb_ok) + ' status=' + String(li_status))
- lws.DisconnectServer()
- destroy lws
复制代码
三个实测细节必须交代:
第一,ConnectServer 在两版上的耗时都是 0 毫秒左右、立即返回 true,哪怕目标端口根本没人监听也一样。它是异步受理制,返回值不承载连接成败。第二,连上之后状态是 1,实测里 Yield 一轮就绪;但也出现过状态迟迟停在 0、几十轮后才翻 1 的情况(两版都有,见第十二节坑清单)。第三,收消息不靠任何轮询函数,全靠 onmessage 事件,所以收数据必须写事件子类,见下一节。
四、收消息:写一个事件子类
uo_websocket_client 的事件脚本要在子类里重载。建一个标准类用户对象 nvo_ws_c,祖先类型选 uo_websocket_client,然后写四个事件:
- // 实例变量
- boolean ib_conn = false // 是否见过 onconnect
- boolean ib_disc = false // 是否见过 ondisconnect
- boolean ib_fail = false // 是否见过 onfail
- string is_rx = '' // 收到的消息累积
- string is_fail = '' // 失败信息累积
- // onmessage 事件
- is_rx = is_rx + as_msg
- // onconnect 事件
- ib_conn = true
- // ondisconnect 事件
- ib_disc = true
- // onfail 事件
- ib_fail = true
- is_fail = is_fail + as_msg
复制代码
用的时候 create nvo_ws_c 而不是 create uo_websocket_client,连接收发逻辑完全一样,事件就都落到你自己的变量里了。本机实测:对本地回环 echo 服务发 hello67,onconnect 置位、onmessage 收到 7 字符回显、断开干净,两版一致。
onping 与 onpong 这对事件用于对端主动探测;onheartbeat 是「心跳检测失败」信号,官方示例的日志文案写的就是心跳检测失败四个字。心跳间隔由 ConnectServer 第三参指定,示例里用 3000(毫秒)。
五、GetStatus 四态与异步连接的实测行为
GetStatus 的四个返回值源码注释写得很清楚:0 连接中(connecting)、1 已打开(open)、2 关闭中(closing)、3 已关闭(closed)。实测读数:
| 场景 | 实测返回 | 说明 | | create 之后未连接 | 3 | 已关闭态 | | ConnectServer 刚返回 | 0 | 进入连接中 | | 握手完成(Yield 泵到) | 1 | 可收发 | | DisconnectServer 之后 | 3 | 回到已关闭 |
两个值得记住的反直觉点。其一,连死端口(127.0.0.1 上没人监听的端口)ConnectServer 同样立即返回 true、状态进 0——受理和成败是两件事,成败判定只能靠等状态翻 1、onconnect 置位,或等 onfail 与 onheartbeat 报警。其二,从未连接过的对象直接调 DisconnectServer,两版都返回 true。true 在这套对象里多数时候只表示「指令被受理」,不是「事情做成了」,这个口径和服务端 Broadcast、SendMessage 的判读规则一脉相承。
六、中文与编码:SendMessage 的 toUtf8 开关与 ToUTF8 往返
WebSocket 协议层传的是字节,中文怎么办取决于两端约定。uo_websocket_client 给了三层工具:
SendMessage 第三重载带 toUtf8 参数,true 时组件替你把字符串转成 UTF-8 再发。sendMessage 的 blob 重载则完全交给调用方:先自己 ToUTF8,再发字节。
ToUTF8 与 FromUTF8 这对转换函数实测:中文字符串(4 个汉字)经 ToUTF8 得到 12 字节的 blob(每字 3 字节,标准 UTF-8),FromUTF8 转回来与原文完全相等,往返无损,两版读数一致。
- string ls_cn
- blob lb_utf
- string ls_back
- ls_cn = '中文测试'
- lb_utf = lws.ToUTF8(ls_cn) // 实测 Len(lb_utf) = 12
- ls_back = lws.FromUTF8(lb_utf) // 实测与 ls_cn 完全相等
复制代码
发英文与纯 ASCII 时三种重载都一样;发中文推荐固定用「string 加 toUtf8=true」或「先 ToUTF8 再发 blob」其中一种,别在两端各自为政。
七、setProxy 与 setHeader:两个调试期的救命稻草
setProxy 写上本机代理地址(例如 http://127.0.0.1:8888),客户端流量就走进 Fiddler 之类的抓包工具,握手报文看得一清二楚,WebSocket 问题十有八九是靠这一步定位的。setHeader 在握手请求里追加自定义头,官方示例注释原话是:对于带路径的 WebSocket 连接,需要设置 Origin 头才能正确连接。第三方 WebSocket 测试服务普遍校验 Origin,连不上时先想到这一手。
八、服务端全貌:9 个事件、30 余个方法
uo_websocket_server 同样继承 nonvisualobject,但它的定位不只是 WebSocket 服务端——源码注释明说这是「Web 服务器,包括 http 和 websocket 功能」,静态页面、HTTP 接口、WebSocket 推送一体化。事件共 9 个:
| 事件 | 参数 | 触发时机 | | onhttp | unsignedlong id | 收到 HTTP 请求,id 是消息号 | | onconnect | unsignedlong id, unsignedlong handle | WebSocket 客户端连入 | | ondisconnect | unsignedlong id | WebSocket 客户端断开 | | onmessage | unsignedlong id, unsignedlong handle | 收到 WebSocket 消息 | | onping | unsignedlong id, unsignedlong handle | 客户端 ping 服务端 | | onpongtimeout | unsignedlong id, unsignedlong handle | 服务端 ping 客户端不通(疑似掉线) | | onvalidate | 返回 boolean | 连接准入校验,返回 true 放行、false 拒绝 | | timer | 返回 boolean | 指定 timer 时间到时触发,可做心跳巡检 | | onschedule | 无 | 计划任务触发(SetSchedule 配置的 cron) |
方法按用途分六组。连接管理:Start、Stop、CloseClient(强踢客户端)、GetClientList(取客户端列表进 uo_json)、GetClientByHandlereadonly(按句柄查单个客户端)。消息收发:GetMessage(按消息号取内容进 uo_json)、SendMessage 三个重载、Broadcast 三个重载。HTTP 响应:SetResponse 八个重载(内容支持 uo_json、uo_xml、blob、string,带不带 HTTP 状态码各四个)、SetResponseFile(大文件响应省内存,autoDelete 控制发完即删)、RemoveMessage(用完的消息从内存移除)。全局变量仓:InitVar、CloseVar、SetVar、GetVar、VarExist、VarExpired、RemoveVar、SaveVar、RestoreVar。辅助:GetNextID、GetTempFilename、GetCPUCount、QueryVar、GetError、Lock、UnLock、SetSchedule、GetRequestInfo。
这里有一个要拿放大镜看的名字:GetClientByHandlereadonly。源码声明里 readonly 和方法名粘连在了一起,这个对象的对外方法名就叫这个——中间不带空格、readonly 五个字母全小写跟在 Handle 后面。本机编译探针确认了这个怪名字真实存在且必须原样调用,写成 GetClientByHandle 反而 C0051。
九、Start 的配置键全表(官方示例逐键核对)
Start 只有一个签名:boolean Start(readonly uo_json jsParam)。所有配置都通过 uo_json 的 set 塞进去。官方示例对象里出现过的键完整列出(静态核对自示例源码,运行期验证见第十一节的边界说明):
| 键 | 示例值 | 作用 | | ip | 0.0.0.0 | 监听地址 | | port | 9000 | 监听端口 | | hwnd | handle(parent) | 接收通知的窗口句柄 | | messageid | 9000 | 通知消息号,窗口 Other 事件里比对 message.number | | root | ..\root\ | HTML 静态内容根目录 | | object | nvo_webreponse_db | 响应事件对象名,须含 onhttp 等四个事件 | | threadcount | CPU 数乘 2 | 工作线程数 | | triggermode | 0 或 1 | 0 预建 PB 对象;1 需要响应时才建 | | ftpuser / ftppass | ftp / 123456 | 上传账号 | | cert_file / key_file / dh_file | 路径 | wss 与 https 证书(需 VIP 版 tls DLL) | | /cgi/N/app、/cgi/N/ext、/cgi/N/env/名称 | 路径 | cgi 网关(php、python 等),N 从 0 起 | | raw | false | 响应 JSON 是否带原始数据(调试用,上线关) | | log / log_file / log_content | true / log/pss.log / false | 日志开关与落点 | | monitor | true | 监控模式,消息转发主界面抓包 | | ping | 5 | 心跳秒数,小于 5 按 5,默认 0 不启用 | | timer | 5 | timer 事件秒数,小于 5 按 5,默认 0 不启用 |
事件到达机制也交代清楚:Start 之后,组件把网络事件折算成窗口消息发给 hwnd 指定的窗口;窗口的 Other 事件里判断 message.number 等于 messageid,再调用响应对象的对应事件。官方示例的写法是在 Other 事件里触发 onhttp(message.wordparm, message.longparm)。没有窗口、没有消息泵的环境里,这套机制跑不起来——这正是第十一节实测边界的原因。
十、服务端基础 API 实测(不依赖 Start 的部分)
Start 之外的大多数方法在未启动状态下就能实测。两轨实测读数:
| 调用 | PB10 实测 | PB12.5 实测 | | 未启动就 Stop() | false | false | | 未启动 GetClientList(json) | 返回 0,json 内容为空对象 | 同 | | GetNextID() 三连 | 32 位十六进制串,三者互不相同 | 同(值随机,格式一致) | | GetTempFilename('txt') | 返回 57 字符临时文件名 | 同 | | GetCPUCount() | 8 | 8 | | 未出错时 GetError() | 空串 | 空串 | | 未初始化 VarExist('k67') | false | false | | CloseClient(1)、RemoveMessage(1) 乱调 | 不崩、无返回值 | 同 | | 无客户端 Broadcast('hi') | false | false | | SendMessage(1,'x') 假句柄 | false | false |
全局变量仓单独说:SetVar('k67','v67') 之后 VarExist 翻 true、GetVar 取回原值、VarExpired 在未设超时时返回 false、RemoveVar 之后 VarExist 回 false。更实用的是跨实例共享——实例 A SetVar 存进去、实例 B GetVar 取得到,两版都验证通过。这套变量仓就是给响应对象在多线程间传状态用的,配合 SetVar 的超时重载还能做会话过期。变量仓的 InitVar 落库形态(本地 db 文件、带口令、sync 与 cacheSize 参数)本轮未实测,读者用到时以官方示例为准。
- // 服务端变量仓最小示例(实测通过,逻辑与实跑代码一致)
- uo_websocket_server lws
- any la
- boolean lb
- lws = create uo_websocket_server
- lws.SetVar('session_abc', 'v67') // 存
- lb = lws.VarExist('session_abc') // 实测 true
- la = lws.GetVar('session_abc') // 实测取回 v67
- lws.RemoveVar('session_abc')
- lb = lws.VarExist('session_abc') // 实测 false
- MessageBox('变量仓', 'exist=' + String(lb))
- destroy lws
复制代码
十一、实测边界:哪些部分没有实跑
如实交代,不含糊。其一,Start 的运行期行为未实测。本机测试架(无窗口、无消息泵)里调用 Start 配置了最小键(仅 port)后调用阻塞不返回,等不到成对的 Stop 读数;结合第九节的 hwnd、messageid、object 三键机制判断,Start 是消息驱动设计,完整启停与事件路由必须连同窗口一起验证,本文不冒充实测结论,读者部署时以官方示例工程为准。其二,onpongtimeout、timer、onschedule、SetResponse 系列、SetResponseFile、InitVar 落库、GetRequestInfo 的多进程分发模式,都只做了编译级核对(探针调用全部编译通过,签名真实存在),运行级未测。其三,autoReconnect 参数置 true 时的重连节奏未测。
这些没测的部分都给足了静态核对材料:签名表来自导出源码逐行确认,配置键表来自官方示例源码逐键摘录,编译级验证由双轨全量重建背书。
十二、坑清单(十六条)
- GetClientByHandlereadonly 是真方法名:readonly 四个字母粘连在 Handle 后面、全小写,写成 GetClientByHandle 报 C0051。
- ConnectServer 返回 true 不代表连上,只是受理;死端口也返回 true。
- 判连接就绪用 GetStatus()=1 或 onconnect 事件,但两者都可能滞后——实测两版都出现过 Yield 几十轮状态仍为 0、实际却已能收发的情况,发消息前不要只信一次读数。
- 收消息必须写事件子类重载 onmessage,客户端没有任何「取消息」方法。
- 从未连接就 DisconnectServer 返回 true,别拿它当连接判据。
- SendMessage 未连接返回 false(string 与 blob 重载都一样),发送前先看状态。
- 广播 Broadcast 无客户端时返回 false,是「没发出去」不是「出错了」。
- 心跳间隔是毫秒(示例值 3000),别当秒用。
- 中文发送固定走 toUtf8=true 或 ToUTF8 之后的 blob 重载,两端约定要一致。
- ToUTF8 对中文是每字 3 字节,按 Len() 拿到的是字节数,别拿它当字符数。
- 服务端 Start 必须配 hwnd、messageid、object 三键才有事件路由,缺了就收不到网络事件。
- object 指定的响应对象必须自带 onhttp、onconnect、ondisconnect、onmessage 四个事件。
- 处理完的 HTTP 消息记得 RemoveMessage,示例注释原话是「记得把这条消息从内存删除」。
- threadcount 官方建议 CPU 数乘 2;用 SQLite 后端时示例注释特意写成只用 1 线程。
- onvalidate 返回 false 可以在准入层直接拒绝连接,鉴权前置到这里比收到消息再踢省事。
- 测试机实测中出现过偶发的进程不退出:连接建立后若长时间空闲(只挂心跳不发数据),个别运行在断开或销毁阶段收不住。规避做法与本文示例一致——等待循环设上限、用完即 DisconnectServer、用完即 destroy,不要让客户端长时空闲挂着。
十三、与 Socket 三件套怎么选
本系列前文写过 uo_socket_server、uo_socket_client、uo_socket_udp_client。裸 Socket 给的是字节流,协议自己定;WebSocket 给的是标准帧协议加标准 URL 握手,浏览器和其它语言零成本接入。内部系统之间点对点传数据,Socket 三件套足够;要跟网页端、手机端互通,或者要 HTTP 静态页加推送一体交付,WebSocket 这对对象才是正解。另外 Socket 客户端的 Open 是同步阻塞的(实测卡满约 2 秒),WebSocket 客户端的 ConnectServer 是异步受理立即返回,交互体验完全不同,这一点在界面上做连接按钮时尤其重要。
十四、PB12.5 差异
客户端与服务端全部实测项两版行为一致:状态机读数、发送返回值、UTF-8 往返字节数、变量仓读写、GetNextID 格式、错误读数均逐项相同。唯一反复出现的波动是 GetStatus 与 onconnect 的到达时机存在调度竞态(第十二节第 3 条),PB10 与 PB12.5 上都各出现过,属两版共有现象,不构成版本差异。未实测部分(Start 生命周期、事件路由、心跳节奏)两版同样都未测,不做差异断言。
十五、结语
一对对象把 PB 从「只能当客户端调别人」解放成「自己就能当实时服务」。客户端三个重载、七个事件,把连接、心跳、断线、编码四件事全包了;服务端把 HTTP 静态服务、HTTP 接口、WebSocket 推送、多线程变量仓揉在一个对象里。用对 hwnd、messageid、object 三键,其余按官方示例的键表逐项配,WebSocket 这层协议复杂度就都被 PBIDEA 吃掉了。把 ConnectServer 返回 true 只当受理、成败看状态与事件,这一条口径立住了,这对对象就很难用出事故。 |