使用JsonRPC实现前后台
使用 JsonRPC 实现前后台分离1. 把程序拆分为前后台1.1 为何要拆分对于一个功能比较复杂的程序如果所有代码界面显示、业务逻辑、硬件操作都写在一起会带来很多麻烦牵一发而动全身比如要更换一个 LED 的控制引脚或者把温湿度传感器从 DHT11 换成其他型号你需要去修改那些直接操作硬件的函数。如果这些函数和界面代码混在一起修改时很可能不小心破坏界面的功能。团队协作困难做 Qt 界面的人和做底层驱动的人必须频繁地沟通代码改动。任何一方的修改都可能导致另一方编译不通过。稳定性差前台界面一个简单的 bug比如空指针就可能让整个程序崩溃连带着硬件控制也失效。所以我们把一个完整的程序拆成两个独立的进程程序前台程序GUI只负责显示界面、接收用户点击和输入。它不直接操作任何硬件而是把用户的要求比如“打开 LED”打包成一个请求通过网络发给后台然后等待后台返回结果并显示。后台程序APP / Service负责真正“干活”——控制 LED、读取温湿度传感器、处理复杂计算等。它像一个 24 小时值班的服务员安静地等待前台的请求执行完操作后把结果返回。这样做的好处非常明显更换硬件比如改 LED 引脚时只需要修改后台程序前台 Qt 程序完全不用改动也不用重新编译。想美化界面或调整布局只需要修改前台后台程序纹丝不动。前后台可以由不同团队独立开发只要约定好通信的“接口格式”即可。1.2 如何拆分前台和后台属于不同的“进程”。进程之间要通信就需要**进程间通信IPC**技术比如网络通信、管道、共享内存等。本课程选用的是基于网络通信的 JsonRPC 远程调用RPCRemote Procedure Call远程过程调用。通俗讲就是让前台程序可以像调用本地函数一样去调用后台程序里的函数。JSON一种非常流行的数据格式便于人和程序阅读也便于网络传输。前后台通过网络交换 JSON 格式的数据就能实现“前台发出请求 → 后台执行 → 前台收到结果”。2. 网络通信概述2.1 IP 和端口在网络中传输数据就像寄快递一样必须明确三要素源、目的、长度。IP 地址用来定位到某一台设备电脑、手机、开发板。好比快递单上的“城市街道门牌号”。127.0.0.1是一个特殊 IP表示“本机”或“本地回环地址”。同一台设备上的两个程序可以用这个地址通信。端口号用来定位到该设备上的某个具体程序。好比快递单上的“收件人姓名”。一个 IP 地址下有 65535 个端口。0~1023 是“知名端口”被系统服务占用例如80HTTP 网页服务、22SSH 远程登录。我们自己的程序一般使用 1024~65535 之间的端口例如 8888、1234。服务器如何区分同一台电脑上两个不同的浏览器当你用 Chrome 和 Firefox 同时访问百度时你的电脑源 IP 相同向百度服务器目的 IP 相同的 80 端口目的端口相同发送请求。百度返回数据时会根据源端口来区分操作系统为 Chrome 分配一个临时端口比如 52341为 Firefox 分配另一个比如 52342。服务器把响应的目的端口设置成这些源端口你的电脑就能把数据正确交给对应的浏览器。所以源IP:源端口标识发送者目的IP:目的端口标识接收者。服务器依靠(源IP, 源端口)的组合来区分不同连接。2.2 网络传输中的两个角色Server 和 Client服务器Server被动等待。它启动后会绑定一个固定的端口然后一直“监听”等着别人来连接。它从不主动发起连接。我们的后台程序就是服务器角色。客户端Client主动发起。它主动向服务器的 IP 和端口发起连接请求。我们的前台 Qt 程序就是客户端角色。2.3 两种传输方式TCP 和 UDP在网络的“运输层”有两个最常用的协议特点TCPUDP连接性面向连接。通信前必须先建立连接三次握手就像打电话。无连接。直接把数据包发出去就像寄信不确认对方是否收到。可靠性可靠。丢包会重传乱序会重组保证数据完整有序到达。不可靠。丢包、乱序都不管只“尽最大努力”。速度较慢。因为要维护连接、确认、重传等头部开销大20字节。较快。无复杂机制头部开销小8字节。适用场景文件传输、网页浏览、数据库、RPC 调用等要求数据完整的场景。视频通话、在线游戏、实时音视频等允许少量丢包但对延迟敏感的场景。为什么有了 TCP 还要 UDP比如视频通话偶尔花屏一下可以接受但如果用 TCP一旦丢包就会卡住等待重传反而更影响体验。所以实时应用更喜欢 UDP。在我们的 JsonRPC 前后台通信中必须保证请求和响应都不丢失、不乱序所以我们选择TCP。TCP 和 UDP 的交互流程简图TCP面向连接流模式服务器socket() → bind() → listen() → accept()阻塞客户端socket() → connect() → 发送/接收数据 → close()连接建立后双方可以随时用 send() / recv() 交换数据。UDP无连接数据报模式服务器socket() → bind() → recvfrom() / sendto()客户端socket() → sendto() / recvfrom()无需 connect()每次发送都要指定对方地址。3. 网络编程主要函数介绍下面这些函数是编写 TCP/UDP 程序的基础。它们都是操作系统提供的我们只需要按顺序调用即可。3.1 socket() —— 创建“电话”cint socket(int domain, int type, int protocol);domain协议族。常用AF_INETIPv4 网络通信AF_UNIX单机内进程通信。type通信类型。SOCK_STREAM表示 TCPSOCK_DGRAM表示 UDP。protocol一般填 0 即可系统会自动选择。返回值成功返回一个套接字描述符可以理解为一个文件描述符后续操作都用它失败返回 -1。3.2 bind() —— 给“电话”贴上号码牌服务器用cint bind(int sockfd, struct sockaddr *my_addr, int addrlen);将 socket 绑定到一个具体的 IP 地址和端口号。这样客户端才知道该找谁。sockfdsocket() 返回的描述符。my_addr包含 IP 和端口信息的结构体。常用struct sockaddr_in来填充然后强制转换。示例cstruct sockaddr_in server_addr; server_addr.sin_family AF_INET; server_addr.sin_port htons(8888); // 端口号htons 转成网络字节序 server_addr.sin_addr.s_addr INADDR_ANY; // 表示监听本机所有网卡 bind(sockfd, (struct sockaddr*)server_addr, sizeof(server_addr));3.3 listen() —— 让“电话”处于待机状态服务器用cint listen(int sockfd, int backlog);将 socket 转为被动监听模式准备接受客户端的连接。backlog最大等待队列长度。如果同时有多个客户端连接超过此数会被拒绝。返回值成功 0失败 -1。3.4 accept() —— 接起电话服务器用cint accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);阻塞等待直到有一个客户端连接进来。当连接成功时会返回一个新的 socket 描述符专门用于和这个客户端通信。原来的监听 socket 可以继续等待其他连接。addr和addrlen会填充客户端的 IP 和端口信息如果你想知道是谁打来的。3.5 connect() —— 拨打电话客户端用cint connect(int sockfd, struct sockaddr *serv_addr, int addrlen);客户端主动连接服务器。serv_addr中填服务器的 IP 和端口。成功返回 0失败 -1。3.6 send() 和 recv() —— 通话双方都用cssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t recv(int sockfd, void *buf, size_t len, int flags);send把buf中的数据发送出去返回实际发送的字节数。recv从对方接收数据存入buf返回实际接收的字节数0 表示对方关闭连接。flags一般填 0。3.7 sendto() 和 recvfrom() —— 用于 UDPcssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);UDP 不需要建立连接所以每次发送都要指定目标地址dest_addr每次接收都能获得发送方的地址src_addr。4. TCP 编程示例完整可运行这里给出一个最经典的 TCP 回显服务器把客户端发来的数据原样返回和对应的客户端。你可以先在 Ubuntu 上编译运行感受一下网络通信的过程。4.1 服务器程序server.cc#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BACKLOG 10 int main() { int listen_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_addr_len sizeof(client_addr); char recv_buf[1024]; int ret; // 1. 创建 socket listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return -1; } // 2. 绑定地址和端口 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); server_addr.sin_addr.s_addr INADDR_ANY; if (bind(listen_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(bind); return -1; } // 3. 开始监听 if (listen(listen_fd, BACKLOG) 0) { perror(listen); return -1; } printf(Server is listening on port %d...\n, SERVER_PORT); while (1) { // 4. 接受客户端连接 client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_addr_len); if (client_fd 0) { perror(accept); continue; } printf(New connection from %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); // 5. 处理客户端这里用简单的 fork 或循环处理 // 为了简洁我们只接收一次数据并回应 ret recv(client_fd, recv_buf, sizeof(recv_buf) - 1, 0); if (ret 0) { recv_buf[ret] \0; printf(Received: %s\n, recv_buf); send(client_fd, recv_buf, ret, 0); } close(client_fd); printf(Connection closed.\n); } close(listen_fd); return 0; }4.2 客户端程序client.cc#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 int main(int argc, char *argv[]) { if (argc ! 2) { printf(Usage: %s server_ip\n, argv[0]); return -1; } int sock_fd; struct sockaddr_in server_addr; char send_buf[1024]; char recv_buf[1024]; // 1. 创建 socket sock_fd socket(AF_INET, SOCK_STREAM, 0); if (sock_fd 0) { perror(socket); return -1; } // 2. 准备服务器地址 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); if (inet_aton(argv[1], server_addr.sin_addr) 0) { printf(Invalid IP address\n); return -1; } // 3. 连接服务器 if (connect(sock_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect); return -1; } // 4. 发送数据 printf(Enter message: ); fgets(send_buf, sizeof(send_buf), stdin); send(sock_fd, send_buf, strlen(send_buf), 0); // 5. 接收回应 int len recv(sock_fd, recv_buf, sizeof(recv_buf) - 1, 0); if (len 0) { recv_buf[len] \0; printf(Server echoed: %s\n, recv_buf); } close(sock_fd); return 0; }4.3 上机实验编译bashgcc server.c -o server gcc client.c -o client运行服务器./server另开一个终端运行客户端./client 127.0.0.1输入一行文字回车服务器会返回相同的内容。这个例子虽然简单但已经包含了 TCP 通信的全部核心步骤。5. JSON-RPC 示例与情景分析理解了基本的 TCP 通信后我们再来看一个更高层的封装JSON-RPC。它让你不用手动拼接 JSON 和解析而是直接像调用本地函数一样调用远程函数。5.1 JSON 是什么JSONJavaScript Object Notation是一种轻量级的数据交换格式长得像这样json{ name: 张三, age: 25, isStudent: false, hobby: [reading, coding], address: { city: 深圳, zip: 518000 } }用{}表示对象内部是键值对键必须用双引号。用[]表示数组里面可以放任意类型的值。值可以是字符串、数字、布尔值true/false、null、对象、数组。JSON 的优点是可读性好并且几乎所有编程语言都有现成的库来解析和生成它。5.2 常用的 JSON 函数cJSON 库我们使用 C 语言时常用cJSON库来处理 JSON。它的核心是一个结构体cJSON里面包含了类型、值、子节点等。创建 JSONccJSON *root cJSON_CreateObject(); // 创建空对象 cJSON_AddNumberToObject(root, age, 25); // 添加数字 cJSON_AddStringToObject(root, name, 张三); // 添加字符串 cJSON_AddFalseToObject(root, isStudent); // 添加 false char *json_str cJSON_Print(root); // 转换成字符串 printf(%s\n, json_str); free(json_str); cJSON_Delete(root); // 释放内存解析 JSONccJSON *root cJSON_Parse(json_string); // 从字符串解析 cJSON *age_item cJSON_GetObjectItem(root, age); if (cJSON_IsNumber(age_item)) { int age age_item-valueint; // 注意推荐用 valuedouble但 valueint 也可用 } cJSON_Delete(root);从数组中取元素ccJSON *array cJSON_GetObjectItem(root, hobby); cJSON *first cJSON_GetArrayItem(array, 0); printf(%s\n, first-valuestring);5.3 服务器程序启动使用 jsonrpc-c 库我们使用别人写好的jsonrpc-c库它基于libev事件循环可以自动处理网络和 JSON 解析。服务器核心代码cjrpc_server my_server; jrpc_server_init(my_server, 1234); // 监听 1234 端口 jrpc_register_procedure(my_server, say_hello, sayHello, NULL); jrpc_register_procedure(my_server, add, add, NULL); jrpc_server_run(my_server); // 开始循环处理请求 jrpc_server_destroy(my_server);这里注册了两个函数say_hello和add。当前台发来method: add的请求时服务器就会自动调用add函数。5.4 客户端程序发出请求客户端需要自己构造 JSON 字符串并通过 socket 发送。c// 构造请求 sprintf(buf, {\method\: \add\, \params\: [%d, %d], \id\: 1}, a, b); send(sock, buf, strlen(buf), 0);然后读取服务器返回的 JSON从中提取result字段。5.5 服务器处理请求服务器端注册的add函数示例ccJSON* add(jrpc_context *ctx, cJSON *params, cJSON *id) { cJSON *a cJSON_GetArrayItem(params, 0); cJSON *b cJSON_GetArrayItem(params, 1); int sum a-valueint b-valueint; return cJSON_CreateNumber(sum); // 返回结果会自动封装成 {result: sum} }5.6 客户端程序解析数据客户端收到响应后用 cJSON 解析ccJSON *root cJSON_Parse(recv_buf); cJSON *result cJSON_GetObjectItem(root, result); int sum result-valueint; cJSON_Delete(root);5.7 上机实验Ubuntu PC 上安装依赖sudo apt install libtool autoconf make gcc编译 libev 和 jsonrpc-c步骤略详见您提供的资料。编译测试程序json-rpc_test。运行bash./rpc server # 后台运行服务器 ./rpc add 3 4 # 输出 sum 7 ./rpc hello 100ask # 输出 Hello, 100ask也可以用netcat直接测试bashecho {method: add, params: [2,4], id: 2} | nc localhost 12346. 基于 JSON-RPC 操作硬件现在我们把前面的知识应用到真实的嵌入式开发板上让后台程序控制 LED 和 DHT11 温湿度传感器前台 Qt 程序通过网络远程调用。6.1 功能目标前台程序可以发送led_control请求让后台打开或关闭某个 LED。前台程序可以发送dht11_read请求让后台读取温湿度并返回数值。6.2 编写后台程序后台程序需要实现硬件操作函数比如读写/sys/class/leds或/dev/dht11。将这些函数包装成 RPC 可调用的形式参数和返回值都是 cJSON*。示例LED 控制 RPC 方法ccJSON* rpc_led_control(jrpc_context *ctx, cJSON *params, cJSON *id) { cJSON *led_num_item cJSON_GetArrayItem(params, 0); cJSON *status_item cJSON_GetArrayItem(params, 1); if (!led_num_item || !status_item) { return cJSON_CreateString(error: need [led, onoff]); } int led led_num_item-valueint; int on status_item-valueint; // 假设 led_control() 是真正的硬件操作函数 int ret led_control(led, on); return cJSON_CreateString(ret 0 ? OK : FAIL); }然后在main中注册cjrpc_register_procedure(server, rpc_led_control, led_control, NULL); jrpc_register_procedure(server, rpc_dht11_read, dht11_read, NULL);6.3 交叉编译与上机实验因为开发板通常是 ARM 架构我们需要在 PC 上用交叉编译工具链编译。交叉编译 libevbash./configure --hostarm-buildroot-linux-gnueabihf --prefix$PWD/tmp make make install交叉编译 jsonrpc-c指定 libev 的头文件和库路径。编译后台程序rpc_server链接 libev 和 jsonrpc-c。编译前台程序稍后介绍。将编译好的rpc_server和 Qt 程序通过adb push或 NFS 拷贝到开发板。在开发板上运行bash./rpc_server ./qt_app6.4 使用多线程改进后台程序原始的jsonrpc-c库是单线程的一次只能处理一个客户端的请求如果这个请求执行时间很长比如读取网络或等待传感器稳定其他客户端就会被阻塞。改进方法在服务器中每accept一个新连接就创建一个新的线程或进程去处理该连接上的所有后续 RPC 请求。伪代码cwhile (1) { client_fd accept(listen_fd, ...); pthread_create(thread_id, NULL, client_handler, client_fd); pthread_detach(thread_id); } void *client_handler(void *arg) { int fd *(int*)arg; // 在这个线程中使用这个 fd 进行 jrpc 处理 // 直到客户端断开 close(fd); return NULL; }这样即使一个客户端在读取慢速设备也不会影响其他客户端的请求响应。7. 基于 JSON-RPC 改造 Qt 程序最后我们把原来的 Qt 程序里面直接调用硬件函数改成通过 RPC 远程调用后台程序。7.1 合并程序修改 Qt 代码原来 Qt 程序中可能有这样的代码cppvoid MainWindow::on_ledButton_clicked() { led_control(0, 1); // 直接操作硬件 }现在我们要把它改成cppvoid MainWindow::on_ledButton_clicked() { // 通过 RPC 调用后台的 led_control 方法 callRpc(led_control, {0, 1}); }具体步骤在 Qt 项目的.pro文件中添加QT network。包含头文件#include QTcpSocket并声明一个QTcpSocket *socket。在构造函数或初始化函数中连接后台服务器cppsocket new QTcpSocket(this); socket-connectToHost(127.0.0.1, 1234); // 后台地址和端口 if (!socket-waitForConnected(3000)) { qDebug() 连接后台失败; }实现一个通用的callRpc函数它负责构造 JSON 请求包括 method、params、id。通过 socket 发送。等待并读取响应。解析 JSON返回 result 部分。把所有原来操作硬件的地方都替换成调用callRpc。注意为了避免界面卡顿callRpc中读取响应时应该用事件循环或异步方式不要直接阻塞 UI 线程。简单起见可以使用QEventLoop配合readyRead信号来实现同步等待。7.2 上机实验我们提供了已经改好的 Qt 程序压缩包LED_and_TempHumli.tar.bz2以及自启动脚本rcS和S99myqt。部署步骤在 Ubuntu 上通过 adb 把文件推送到开发板bashadb push LED_and_TempHumi /root/ adb push rpc_server /root/ adb push rcS /etc/init.d/ adb push S99myqt /etc/init.d/在开发板上设置可执行权限bashchmod x /root/LED_and_TempHumi chmod x /root/rpc_server chmod x /etc/init.d/rcS chmod x /etc/init.d/S99myqt手动测试bash/root/rpc_server # 启动后台 /root/LED_and_TempHumi # 启动 Qt 界面如果一切正常可以重启开发板系统会自动启动后台和 Qt 程序通过S99myqt脚本。这样您就完成了一个完整的前后台分离的嵌入式应用。以后无论是换 LED 引脚还是换温湿度传感器都只需要修改后台程序想要修改界面风格只需要修改 Qt 程序。两个部分互不干扰开发和维护都轻松很多。