Primer on WebSockets and JSON-RPC¶
This section provides a brief introduction to the WebSocket and
JSON-RPC protocols used for communication between recsystems and the
Renewal backend, as well as to asynchronous I/O programming techniques (in
particular on Python using asyncio, though the general concepts are
transferrable to other programming languages which support asynchronous
I/O). If you are already familiar with asyncio
, you can skip straight
to the WebSockets primer.
Asynchronous I/O with asyncio
¶
While it is not strictly necessary to understand or even use asynchronous
I/O to implement a recsystem, in practice you will find that due to the
event-driven nature of WebSockets many modern programming language
interfaces for dealing with WebSockets, such as on Python and JavaScript,
use asynchronous I/O techniques. In particular, the renewal_recsystem
package itself uses Python’s asyncio library throughout, so understanding
how it works requires a minimum of background learning, especially if you
have never seen the async/await syntax before.
In particular, all of the below examples of WebSockets and JSON-RPC use
Python and asyncio
, so it useful to provide this additional primer.
A full introduction to asynchronous I/O with coroutines is beyond the scope of this documentation, but Async IO in Python: A Complete Walkthrough is a good, thorough explainer.
At a bare minimum you need to keep in mind the following points:
A function defined using the
async
keyword is a coroutine function. For example:>>> import asyncio >>> async def ticker(ticks=5, interval=1): ... for n in range(ticks): ... print(f'tick {n}') ... await asyncio.sleep(interval) ...
Coroutines look like normal functions (except the
async
keyword) but they are not called like normal functions. If you try to call a coroutine like a normal function, it won’t run, and you’ll just get back the resultingcoroutine
object:>>> ticker() <coroutine object ticker at 0x7f5f780559e0>
To run a coroutine function, you have to prefix its call with the
await
keyword, likeawait ticker()
. However, you can’t useawait
just anywhere, like at the command prompt, or in a normal function. For example, if you try to do this in the Python interactive prompt you’ll just get:>>> await ticker() File "<stdin>", line 1 SyntaxError: 'await' outside function
Note
Unless you are using recent versions of IPython, which has a special feature allowing you to use
await
in interactive prompts.In order to use the
await
keyword, you have to be inside another coroutine function defined withasync
. For example:>>> async def run_ticker(): ... print('starting ticker coroutine') ... await ticker() ... print('finished ticker coroutine') ...
In order to call an
async
function from synchronous code, i.e. from outside anasync
function, you have to pass the coroutine to the event loop which runs the coroutine until completion. The easiest way to do this as of Python 3.7 is to call:>>> import asyncio >>> asyncio.run(run_ticker()) starting ticker coroutine tick 0 tick 1 tick 2 tick 3 tick 4 finished ticker coroutine
This
asyncio.run
call is roughly equivalent to:>>> loop = asyncio.get_event_loop() >>> loop.run_until_complete(run_ticker()) >>> loop.close()
Note that in both cases we did not put
await
in front ofrun_ticker()
. Instead, this function is just passed thecoroutine
object that is returned when it is called withoutawait
. This is the one case where you would not run anasync
function withoutawait
–to kick off the event loop which runs all the coroutines.
To summarize:
async
/await
always go together: If a function is defined withasync
you must call it withawait
(unless passing it directly to an event loop). Conversely, to use theawait
keyword you must be in anasync
function.An event loop is responsible for running
async
functions, i.e. coroutines. To kick off the process of runningasync
functions you will typically wrap them in a “main”async
function which is passed to the event loop.
Note
In some languages, such as JavaScript, coroutines are implicitly
scheduled on the event loop. That is, the event loop is always running,
and if call an async
function without await
it will be scheduled
to run on the event loop, which can lead to confusing and hard to debug
errors. On Python, however, you must explicitly run a coroutine on the
event loop.
Common mistakes¶
Here are some common mistakes in programming with async
/await
in
Python and their symptoms.
Forgetting to await
an async
function:
>>> async def run_ticker():
... print('starting ticker coroutine')
... ticker()
... print('finished ticker coroutine')
...
>>> asyncio.run(run_ticker())
starting ticker coroutine
finished ticker coroutine
In this case you get the warning RuntimeWarning: coroutine 'ticker' was
never awaited
and you can see there is no output from ticker()
.
Forgetting to use await
inside an async
function:
>>> def run_ticker():
... print('starting ticker coroutine')
... await ticker()
... print('finished ticker coroutine')
...
File "<stdin>", line 3
SyntaxError: 'await' outside async function
Trying to run a non-async
function on the event loop:
>>> def run_ticker():
... print('starting ticker coroutine')
... ticker()
... print('finished ticker coroutine')
...
>>> asyncio.run(run_ticker())
Traceback (most recent call last):
...
ValueError: a coroutine was expected, got None
WebSockets¶
Traditionally, communication between a Web server and a client connecting to it is stateless and mostly one-directional: A client connects to the Web server, requests a resource (e.g. an HTML page or a RESTful API), and is returned a response.
WebSockets allow a traditional HTTP request to be “upgraded” to a long-running bi-directional communication channel, where both the client and server can send messages to each other and receive responses until one side closes the connection. The contents of the messages sent over WebSockets can contain anything, so it is up to the application to determine a protocol over WebSockets that the client and server will use.
Typically you will connect to a server supporting WebSockets using a
software library which supports it, using a URI with the protocol prefix
ws://
or wss://
(for secure connections). A successful connection
will return an object representing that connection, on which you can
send()
messages to the server and recv()
(receive) responses. The
exact APIs vary, but they typically follow this design.
Websockets example¶
For example, the Python websockets package provides a simple WebSocket client interface, which can be used roughly like:
import websockets
websocket = websockets.connect('ws://example.com/websocket')
# send a greeting to the server
websocket.send('Hello')
# receive and print the response from the server
print(websocket.recv())
# close the connection
websocket.close()
In fact, the websockets
package uses asyncio
so the real usage
requires await
on all these calls. So let’s try a real-world example
using both a server and a client. The websockets
package also includes
a simple WebSockets server. The easiest way to run these examples is
probably to open two terminals side-by-side, one for the server and one
for the client. We will create a simple echo server in which everything we
say to the server will be echoed back to the client.
First, make sure you have the websockets
package installed:
$ pip install websockets
Now the server code. To implement the server we define a “handler”
async
function. This function is run every time a client connects to
our server and defines how the server communicates to each client over the
WebSocket. It takes as its sole argument a websocket
object which is
passed to it when the client connects. It runs a loop until the client
disconnects:
>>> import websockets
>>> async def handler(websocket):
... while True:
... # receive a message from the client
... message = await websocket.recv()
... # echo the message back to the client
... await websocket.send(message)
...
To start the server we create a simple wrapper that starts the server, on a
given port, and then waits for the server to finish (which should be never
unless an error occurs). If you pass port=0
it will automatically pick
a free port on your system:
>>> async def run_server(handler, host='localhost', port=0):
... server = await websockets.server(handler, host=host, port=port)
... # if port==0 we need to find out what port it's actually
... # serving on as shown below:
... port = server.sockets[0].getsockname()[1]
... print(f'server running on ws://{host}:{port}')
... await server.wait_closed()
...
Finally, start the server like so, optionally providing a port like
port=9090
:
>>> import asyncio
>>> asyncio.run(run_server(handler, port=9090))
server running on ws://localhost:9090
Next on the client side, we can simply connect()
to the server,
send some messages and receive their echoes, and exit:
>>> import websockets, asyncio
>>> async def client(uri):
... websocket = await websockets.connect(uri)
... async def send_recv(msg):
... print(f'-> {msg}')
... await websocket.send(msg)
... resp = await websocket.recv()
... print(f'<- {resp}')
...
... await send_recv("Hello!")
... await send_recv("Goodbye!")
... await websocket.close()
...
Now run the client()
function passing it the port
used for the
server, for example:
>>> asyncio.run(client('ws://localhost:9090'))
-> Hello!
<- Hello!
-> Goodbye!
<- Goodbye!
WebSockets programming for real applications proceed more-or-less in the same fashion, though for complex applications it is necessary to establish a protocol over which the client and server communicate. Typically one side opens with an initial message to which the other side responds. Then they take turns sending messages back and forth, the next message often determined by the contents of the previous message, like any conversation.
JSON-RPC¶
JSON-RPC is a simple protocol for making remote procedure calls (RPC) using JSON-encoded messages. JSON-RPC is not specific to WebSockets, and can be used over any transport mechanism. Renewal uses JSON-RPC to provide structure to the WebSocket communications between the Renewal backend and your recsystem.
With JSON-RPC there is a “server” side which provides a number of functions or “methods” which are executed by the server, and which may produce a result. And there is a “client” side which makes remote procedure calls of the methods provided by the server.
JSON-RPC has two types of methods that a server can implement: “requests” are methods that return a result to the client, whereas “notifications” are just for the client to send some notification to the server, and they do not return a response.
Say, for example, our JSON-RPC server implements a square(x)
method
which can be called via RPC:
def square(x):
return x * x
Then in order to call this method, a client will send a message to the server like:
{"jsonrpc": "2.0", "method": "square", "params": [4], "id": 3}
The server will execute square(4)
and upon completion return the
following result to the server:
{"jsonrpc": "2.0", "result": 16, "id": 3}
Each request and response come with a unique “id” which allows responses to be matched up with the corresponding request (this allows the client to send many requests to the server, which does not necessarily have to respond to requests in the same order it received them).
While JSON-RPC is relatively easy to implement by hand, there are libraries
that help converting function calls to correctly-formatted JSON-RPC requests
and responses. For example, the renewal_recsystem
package uses the
jsonrpcserver package for Python to implement the base recsystem, and the
Renewal backend uses its sister package jsonrpcclient to make RPC calls.
JSON-RPC example¶
Here’s an example of how JSON-RPC can be used over WebSockets, building on our previous example from the WebSockets primer.
In this case the WebSocket client will act as the JSON-RPC server (it provides the functions to run), and the WebSocket server will act as the JSON-RPC client (it will make the RPC calls). This may seem counter-intuitive but in fact models how communication between the Renewal backend and recsystems works.
As in the WebSockets primer, these examples are easiest to run in two separate terminals side-by-side. One for the WebSocket server (JSON-RPC client) side, and one for the WebSocket client (JSON-RPC server) side.
First make sure you have the websockets
package installed, as well as
the JSON-RPC client for websockets
and the jsonrpcserver
package:
$ pip install websockets jsonrpcclient[websockets] jsonrpcserver
WebSocket server side
As before, we must create a handler function which describes what the
WebSocket server will do when a client connects to it. In this case it
will simply greet the client by sending the greeting()
notification
RPC, and then it will request the square of 42 by calling the square()
RPC and print the result, then close the connection:
>>> import websockets
>>> from jsonrpcclient.clients.websockets_client import WebSocketsClient
>>> async def handler(websocket):
... # create a WebSocketsClient wrapping the websocket connection
... rpc_client = WebSocketsClient(websocket)
...
... # use the notify() method by passing it the name of the
... # notification RPC and any arguments it takes
... await rpc_client.notify("greeting", "Hello, friend!")
...
... # use the request() method the same way, but it returns a
... # a response object
... response = await rpc_client.request("square", 42)
...
... # print the result of the call
... print(f"got square(42) = {response.data.result}")
...
Start the WebSockets server as before (e.g. on port 9090):
>>> async def run_server(handler, host='localhost', port=0):
... server = await websockets.server(handler, host=host, port=port)
... # if port==0 we need to find out what port it's actually
... # serving on as shown below:
... port = server.sockets[0].getsockname()[1]
... print(f'server running on ws://{host}:{port}')
... await server.wait_closed()
...
>>> import asyncio
>>> asyncio.run(run_server(handler, port=9090))
server running on ws://localhost:9090
WebSocket client side
The WebSocket client acts as a JSON-RPC server: It provides a few methods that can be called via RPC. When it connects to the server, in this case, the server will immediately call those methods and then close the connection (in the case of the actual Renewal backend it keeps the connection open indefinitely and continues to send notifications and requests to your recsystem as long as both ends are running).
The jsonrpcserver
package provides a @method
decorator that we can
put on top of the definition of any function that we want to be callable
via RPC. In this case we define greeting()
and square()
. Note in
this case we are using the “async dispatcher”, so all functions must be
defined with async
even if they don’t use await
:
>>> from jsonrpcserver import method
>>> @method
... async def greeting(message):
... print(f'Received a greeting from the client: {message}')
...
>>> @method
... async def square(x):
... result = x * x
... print(f'Squaring {x} for the client -> {result}')
... return result
...
Now define a function to connect to the WebSockets server. It waits to
receive RPC calls from the server, and uses the dispatch()
function
which handles the RPC calls by passing them to the appropriate function
from the ones we registered above:
>>> import websockets, asyncio
>>> from jsonrpcserver import async_dispatch as dispatch
>>> async def client(uri):
... websocket = await websockets.connect(uri)
... while True:
... try:
... message = await websocket.recv()
... except websockets.ConnectionClosedOK:
... # We will receive this exception when trying to receive
... # more messages from the WebSocket after the server
... # has closed the connection; so we just exit the loop
... break
... response = await dispatch(message)
... # If response.wanted is False, the message contained a
... # notification call, in which case we do
... # not send a response to the other side.
... if response.wanted:
... await websocket.send(str(response))
...
Now run the client function. On the client side you should see the output as follows:
>>> asyncio.run(client('ws://localhost:9090'))
Received a greeting from the client: Hello, friend!
Squaring 42 for the client -> 1764
While on the WebSocket server side you should see:
got square(42) = 1764
The above examples demonstrate in simplified form how your recsystem will
communicate with the Renewal backend. In fact it does not require much
more than that, though in practical application it can get a little more
complicated; see the source code for renewal_recsystem.server
for example.
Its extra complexity arises from the fact that it can handle multiple
simultaneous RPC calls. In the example above our RPC server just takes
once RPC at a time and sends a result in serial. Whereas the implementation
in renewal_recsystem.server
allows it to handle many RPC calls
simultaneously and send their results as the RPC handler functions complete.
All you have to do is implement the functions described in JSON-RPC API and the rest of the framework will take care of registering them as RPC methods.