WSRPC

两端都能发起调用。

WSRPC 让一条 WebSocket 变得对称。浏览器调用你的处理函数,处理函数也能回调浏览器并等待结果。

浏览器 服务端 连接中

本页面

    连接

      这里的每一行,都是服务端调用本页面画出来的。 本页运行的是随包发布的 JavaScript 客户端,这些帧与 Python 服务端交换的帧完全相同。

      同一个调用,任意一端

      安装包,然后编写处理函数。每个标签页构建的都是上面那个聊天。

      pip install wsrpc-aiohttp

      一个路由类服务一条连接。处理函数先在页面画出你的那行,再用一句台词回答。

      server.py
      import random
      
      from aiohttp import web
      from wsrpc_aiohttp import STATIC_DIR, Route, WebSocketAsync, decorators
      
      QUOTES = [
          ("HAL 9000", "I'm sorry, Dave. I'm afraid I can't do that."),
          ("The Oracle", "There is no spoon."),
          ("T-800", "I'll be back."),
      ]
      
      
      class Socket(WebSocketAsync):
          pass
      
      
      class Chat(Route[Socket]):
          @decorators.proxy
          async def send(self, *, text: str) -> dict:
              # Draw the line in the page that called, and wait for its answer.
              await self.socket.proxy.chat.append(text=text, mine=True)
      
              sender, reply = random.choice(QUOTES)
              await self.socket.proxy.chat.append(sender=sender, text=reply)
              return {"delivered": True}
      
      
      def create_app() -> web.Application:
          Socket.add_route("chat", Chat)
          app = web.Application()
          app.router.add_get("/ws/", Socket)
          app.router.add_static("/js/", STATIC_DIR)
          return app
      
      
      if __name__ == "__main__":
          web.run_app(create_app(), host="127.0.0.1", port=8000)
      
      pip install 'wsrpc-asgi[fastapi]'

      同一个路由类,挂在 FastAPI 的 WebSocket 端点上。

      app.py
      import random
      
      from fastapi import FastAPI, WebSocket
      from wsrpc_asgi import Route, WebSocketAsync, decorators
      
      QUOTES = [
          ("HAL 9000", "I'm sorry, Dave. I'm afraid I can't do that."),
          ("The Oracle", "There is no spoon."),
          ("T-800", "I'll be back."),
      ]
      
      
      class RPC(WebSocketAsync):
          pass
      
      
      class Chat(Route[RPC]):
          @decorators.proxy
          async def send(self, *, text: str) -> dict:
              # Draw the line in the page that called, and wait for its answer.
              await self.socket.proxy.chat.append(text=text, mine=True)
      
              sender, reply = random.choice(QUOTES)
              await self.socket.proxy.chat.append(sender=sender, text=reply)
              return {"delivered": True}
      
      
      RPC.add_route("chat", Chat)
      app = FastAPI()
      
      
      @app.websocket("/ws/")
      async def rpc(websocket: WebSocket) -> None:
          await RPC.serve(websocket)
      
      go get github.com/wsrpc/wsrpc-go

      带点号的路由名,以及处理函数回调页面的类型化调用。

      main.go
      package main
      
      import (
      	"context"
      	"log"
      	"math/rand/v2"
      	"net/http"
      
      	wsrpc "github.com/wsrpc/wsrpc-go"
      	"github.com/wsrpc/wsrpc-go/ws"
      )
      
      type Line struct {
      	Sender string `json:"sender,omitempty"`
      	Text   string `json:"text"`
      	Mine   bool   `json:"mine,omitempty"`
      }
      
      type Sent struct {
      	Text string `json:"text"`
      }
      
      type Delivered struct {
      	Delivered bool `json:"delivered"`
      }
      
      type Seen struct {
      	Seen bool `json:"seen"`
      }
      
      var quotes = []Line{
      	{Sender: "HAL 9000", Text: "I'm sorry, Dave. I'm afraid I can't do that."},
      	{Sender: "The Oracle", Text: "There is no spoon."},
      	{Sender: "T-800", Text: "I'll be back."},
      }
      
      type Chat struct{}
      
      // Draw the line in the page that called, and wait for its answer.
      func (Chat) Send(ctx context.Context, p Sent) (Delivered, error) {
      	peer, _ := wsrpc.FromContext(ctx)
      
      	if _, err := peer.Call[Seen](ctx, "chat.append", Line{Text: p.Text, Mine: true}); err != nil {
      		return Delivered{}, err
      	}
      
      	reply := quotes[rand.IntN(len(quotes))]
      	if _, err := peer.Call[Seen](ctx, "chat.append", reply); err != nil {
      		return Delivered{}, err
      	}
      
      	return Delivered{Delivered: true}, nil
      }
      
      func main() {
      	routes := new(wsrpc.Router)
      	if err := routes.Handle("chat.send", Chat{}.Send); err != nil {
      		log.Fatal(err)
      	}
      
      	rpc, err := ws.NewServer(ws.ServerOptions{Peer: wsrpc.Options{Routes: routes}})
      	if err != nil {
      		log.Fatal(err)
      	}
      
      	mux := http.NewServeMux()
      	mux.Handle("/ws/", rpc)
      	log.Fatal(http.ListenAndServe("127.0.0.1:8000", mux))
      }
      
      pip install wsrpc-aiohttp

      客户端在连接之前注册路由,这样服务端才能调用它。

      client.py
      import asyncio
      
      from wsrpc_aiohttp import Route, WSRPCClient, decorators
      
      
      class Chat(Route):
          @decorators.proxy
          def append(self, *, text: str, sender: str = "you", mine: bool = False) -> dict:
              # The server calls this method. Draw the line and confirm it.
              print(f"{sender}: {text}")
              return {"seen": True}
      
      
      async def main() -> None:
          client = WSRPCClient("http://127.0.0.1:8000/ws/")
      
          # Register the route before the connection opens.
          client.add_route("chat", Chat)
      
          async with client:
              print(await client.proxy.chat.send(text="Hello"))
              await asyncio.sleep(1)
      
      
      if __name__ == "__main__":
          asyncio.run(main())
      
      npm install @wsrpc/client

      页面注册服务端要调用的路由,并画出收到的每一行。

      chat.ts
      import WSRPC from "@wsrpc/client";
      
      interface Line {
        sender?: string;
        text: string;
        mine?: boolean;
      }
      
      const chat = document.getElementById("chat") as HTMLElement;
      const rpc = new WSRPC("/ws/");
      
      // The server calls this route. The page draws what it gets.
      rpc.addRoute("chat.append", (line: Line) => {
        const row = document.createElement("p");
        row.textContent = `${line.sender ?? "you"}: ${line.text}`;
        chat.append(row);
        return { seen: true };
      });
      
      rpc.connect();
      
      export async function say(text: string): Promise<void> {
        await rpc.call<{ delivered: boolean }>("chat.send", { text });
      }
      
      wsrpc-client.d.ts
      // The package ships wsrpc.d.ts. Map it onto the module name once.
      declare module "@wsrpc/client" {
        export default class WSRPC {
          constructor(url: string, reconnectTimeout?: number);
          connect(): void;
          destroy(): void;
          state(): "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED";
          call<T>(method: string, params?: object): Promise<T>;
          addRoute(
            route: string,
            handler: (params: never) => unknown,
            isAsync?: boolean,
          ): void;
        }
      }
      

      随包脚本提供全局 WSRPC。无需构建步骤,也不需要打包器。

      index.html
      <form id="say"><input name="text" autocomplete="off"></form>
      <div id="chat"></div>
      
      <script src="/js/wsrpc.min.js"></script>
      <script>
        const rpc = new WSRPC("/ws/");
      
        // The server calls this route. The page draws what it gets.
        rpc.addRoute("chat.append", function (line) {
          const row = document.createElement("p");
          row.textContent = (line.sender || "you") + ": " + line.text;
          document.getElementById("chat").append(row);
          return {seen: true};
        });
      
        rpc.connect();
      
        document.getElementById("say").addEventListener("submit", function (event) {
          event.preventDefault();
          rpc.proxy.chat.send({text: event.target.text.value});
          event.target.reset();
        });
      </script>
      

      这条连接提供什么

      双向调用

      处理函数调用浏览器注册的路由,并在同一个 socket 上等待结果。

      路由类或普通函数

      可以公开一个函数,也可以公开路由类,其实例与连接同寿。

      并发调用

      每个调用带有自己的 id,结果按任意顺序返回给各自的调用方。

      远端错误在本地抛出

      对端的失败带着类型和消息返回,并在你发起调用的位置抛出。

      生命周期信号

      观察连接、断开和每一次调用,无需包装任何处理函数。

      阻塞代码不占用事件循环

      异步处理函数运行在事件循环上,同步处理函数运行在线程池中。

      一次调用如何传输

      每个帧都是一个 JSON 对象。调用带有 id、方法名和参数,应答以相同的 id 返回。两端都可以发起调用,因此处理函数在服务一次调用时可以反问浏览器。

      浏览器 服务端 {"id": 3, "method": "chat.send", "params": {"text": "hi"}} {"id": 3, "result": {"delivered": true}}
      浏览器调用服务端。
      浏览器 服务端 处理函数 {"id": 5, "method": "chat.send", "params": {"text": "hi"}} {"id": 2, "method": "chat.append", "params": {"text": "hi"}} {"id": 2, "result": {"seen": true}} {"id": 5, "result": {"delivered": true}}
      服务端在它正在服务的调用中调用浏览器。

      它能运行在哪里

      这里的每个框架都有可运行的示例和仓库中的测试。

      aiohttp 服务端

      wsrpc-aiohttp

      • aiohttp

      ASGI 服务端

      wsrpc-asgi

      • FastAPI
      • Starlette
      • Django
      • Channels
      • Litestar
      • Quart
      • Sanic
      • Falcon
      • BlackSheep
      • ASGI

      Node.js 服务端

      @wsrpc/server

      • node:http
      • Express
      • Fastify

      Go 服务端

      github.com/wsrpc/wsrpc-go

      • net/http

      客户端

      @wsrpc/client

      • Browsers
      • Node.js
      • Cloudflare Workers

      选择哪个 Python 包

      两种实现,一个协议。Python 对端、浏览器对端和服务端可以任意组合。

      wsrpc-aiohttpwsrpc-asgi
      服务端 aiohttp 处理函数 ASGI 路由、中间件或独立应用
      安装 pip install wsrpc-aiohttp pip install 'wsrpc-asgi[fastapi,client]'
      Python 客户端 基于 aiohttp 基于 websockets
      JavaScript 客户端 已包含 同一个客户端
      JSON 编解码 loads 与 dumps 可替换 相同