diff --git a/README.rst b/README.rst index 9aa4bb8..0869458 100644 --- a/README.rst +++ b/README.rst @@ -30,13 +30,6 @@ aiomsg Pure-Python smart sockets (like ZMQ) for simpler networking -.. figure:: https://upload.wikimedia.org/wikipedia/commons/5/5e/NetworkDecentral.svg - :target: https://commons.wikimedia.org/wiki/File:NetworkDecentral.svg - :alt: Diagram of computers linked up in a network - - :sub:`Attribution: And1mu [CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0)]` - - Table of Contents ----------------- @@ -46,6 +39,43 @@ Table of Contents Demo ==== +Put on your *Software Architect* hat, and imagine a microservices layout +shown in this block diagram: + +.. figure:: https://raw.githubusercontent.com/cjrh/aiomsg/master/images/microservices.svg?sanitize=true + :alt: Layout of an example microservices architecture + +- One of more features are exposed to the world via the *load balancer*, **N**. + For example, this could be *nginx*. +- Imagine the load balancer proxies HTTP requests through to your backend + webservers, **H**. Your webserver may well do some processing itself, but + imagine further that it needs information from other microservices to + service some requests. + - Both instances of **H** are identical, there are two of them for + redundancy. +- One of these microservices is **A**. It's not important what it does, just + that it does "something". +- It turns out that sometimes **A** needs information supplied by another + microservice, **B**. Both **A** and **B** need to do work so it's important + that they can both be scaled horizontally (ignore that "horizontal scaling" + would actually be in a vertical direction in the diagram!). + +The goal of **aiomsg** is to make it simple to construct these kinds of +arrangements of microservices. + +We'll move through each of the services and look at their code: + +.. literalinclude:: examples/demo/h.py + :language: python3 + +TODO: microservice A +TODO: microservice P +TODO: microservice B +TODO: microservice M + +Simple Demo +=========== + Let's make two microservices; one will send the current time to the other. Here's the end that binds to a port (a.k.a, the "server"): @@ -934,7 +964,7 @@ These are the rules: #. **Every payload** in either direction shall be length-prefixed: - .. code-block:: + .. code-block:: shell message = [4-bytes big endian int32][payload] diff --git a/docker/Dockerfile38.demo b/docker/Dockerfile38.demo new file mode 100644 index 0000000..6936591 --- /dev/null +++ b/docker/Dockerfile38.demo @@ -0,0 +1,17 @@ +FROM python:3.8-alpine3.10 + +RUN apk add --update \ + openssl \ + gcc \ + python-dev \ + musl-dev \ + linux-headers \ + libffi-dev \ + libressl-dev \ + && rm -rf /var/cache/apk/* + +COPY . /aiomsg + +RUN pip install -e aiomsg[all] +WORKDIR /aiomsg +CMD ["python", "-m", "pytest", "--cov", "aiomsg", "--cov-report", "term-missing"] diff --git a/examples/demo/docker-compose.yml b/examples/demo/docker-compose.yml new file mode 100644 index 0000000..dd10ddf --- /dev/null +++ b/examples/demo/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3' +services: + h: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + - logvolume01:/var/log + links: + - redis + redis: + image: redis diff --git a/examples/demo/h.dockerfile b/examples/demo/h.dockerfile new file mode 100644 index 0000000..f97cdea --- /dev/null +++ b/examples/demo/h.dockerfile @@ -0,0 +1,10 @@ +FROM python:3 + +WORKDIR /usr/src/app + +#COPY requirements.txt ./ +RUN pip install --no-cache-dir aiomsg + +COPY . . + +CMD [ "python", "./your-daemon-or-script.py" ] diff --git a/examples/demo/h.py b/examples/demo/h.py new file mode 100644 index 0000000..b072e7d --- /dev/null +++ b/examples/demo/h.py @@ -0,0 +1,66 @@ +# microservice: H +import asyncio +import json +import time +from typing import Dict + +from aiomsg import Søcket +from aiohttp import web +from dataclasses import dataclass + + +@dataclass +class Payload: + msg_id: int + req: Dict + resp: Dict + + +REQ_COUNTER = 0 +BACKEND_QUEUE = asyncio.Queue() # TODO: must be inside async context +pending_backend_requests: {} + + +async def backend_receiver(sock: Søcket): + async for msg in sock.messages(): + raw_data = json.loads(msg) + data = Payload(**raw_data) + f = pending_backend_requests.pop(data.msg_id) + f.set_result(data.body) + + +async def backend(app): + async with Søcket() as sock: + await sock.bind("127.0.0.1", 25000) + asyncio.create_task(backend_receiver(sock)) + while True: + await sock.send(time.ctime().encode()) + await asyncio.sleep(1) + + +async def run_backend_job(sock: Søcket, data: Payload) -> Payload: + f = asyncio.Future() + REQ_COUNTER += 1 + pending_backend_requests[REQ_COUNTER] = f + backend_response = await f + data = dict(result=backend_response) + + +async def handle(request): + nonlocal REQ_COUNTER + # TODO: get post data from request to send to backend + + f = asyncio.Future() + REQ_COUNTER += 1 + pending_backend_requests[REQ_COUNTER] = f + backend_response = await f + data = dict(result=backend_response) + return web.json_response(data) + + +app = web.Application() +app.on_startup.append(backend) +app.add_routes([web.post("/process/{name}", handle)]) + +if __name__ == "__main__": + web.run_app(app) diff --git a/examples/lb/README.rst b/examples/lb/README.rst new file mode 100644 index 0000000..a8649ad --- /dev/null +++ b/examples/lb/README.rst @@ -0,0 +1,2 @@ +Load balancer in which each of the workers asks for work. This is much better +than naive round-robin message distribution. diff --git a/examples/lb/producer.py b/examples/lb/producer.py new file mode 100644 index 0000000..399ee9b --- /dev/null +++ b/examples/lb/producer.py @@ -0,0 +1,22 @@ +import asyncio +import logging +import aiorun +import aiomsg +from random import randint + + +async def main(): + async with aiomsg.Søcket() as sock: + await sock.bind() + async for id_, msg in sock.identity_messages(): + payload = dict(n=randint(30, 40)) + logging.info(f"Sending message: {payload}") + await sock.send_json(payload, identity=id_) + await asyncio.sleep(1.0) + + +logging.basicConfig(level="INFO") +try: + aiorun.run(main()) +except KeyboardInterrupt: + pass diff --git a/examples/lb/requirements.txt b/examples/lb/requirements.txt new file mode 100644 index 0000000..42d5101 --- /dev/null +++ b/examples/lb/requirements.txt @@ -0,0 +1 @@ +aiorun diff --git a/examples/lb/run.py b/examples/lb/run.py new file mode 100644 index 0000000..fc3c262 --- /dev/null +++ b/examples/lb/run.py @@ -0,0 +1,38 @@ +# Example: load balancer + +import os +import sys +import shlex +import signal +from pathlib import Path +import subprocess as sp + +this_dir = Path(__file__).parent + + +def make_arg(cmdline): + return shlex.split(cmdline, posix=False) + + +# Start producer +s = f"{sys.executable} {this_dir / 'producer.py'}" +print(s) +producer = sp.Popen(make_arg(s)) + +# Start worker +s = f"{sys.executable} {this_dir / 'worker.py'}" +worker = sp.Popen(make_arg(s)) + +with producer, worker: + try: + producer.wait(20) + worker.wait(20) + except (KeyboardInterrupt, sp.TimeoutExpired): + producer.send_signal(signal.CTRL_C_EVENT) + worker.send_signal(signal.CTRL_C_EVENT) + + # try: + # producer.wait(20) + # worker.wait(20) + # except KeyboardInterrupt: + # pass diff --git a/examples/lb/worker.py b/examples/lb/worker.py new file mode 100644 index 0000000..7971940 --- /dev/null +++ b/examples/lb/worker.py @@ -0,0 +1,53 @@ +import asyncio +import logging +import signal + +import aiorun +import aiomsg +from concurrent.futures import ProcessPoolExecutor as Executor + + +def fib(n): + if n < 2: + return n + return fib(n - 2) + fib(n - 1) + + +async def fetch_work(sock: aiomsg.Søcket) -> dict: + await sock.send(b"1") + work = await sock.recv_json() + return work + + +async def main(executor): + async with aiomsg.Søcket() as sock: + await sock.connect() + while True: + # "Fetching" is actually a two-step process, a send followed + # by a receive, and we don't want to allow shutdown between + # those two operations. That's why there's a guard. + work = await fetch_work(sock) + logging.info(f"Worker received work: {work}") + # CPU-bound task MUST be run in an executor, otherwise + # heartbeats inside aiomsg will fail. + executor_job = asyncio.get_running_loop().run_in_executor( + executor, fib, work["n"] + ) + result = await executor_job + logging.info(f"Job completed, the answer is: {result}") + + +def initializer(): + """Disable the handler for KeyboardInterrupt in the pool of + executors. + + NOTE that the initializer must not be inside the __name__ guard, + since the child processes need to be able to execute it. """ + signal.signal(signal.SIGINT, signal.SIG_IGN) + + +# This guard is SUPER NECESSARY if using ProcessPoolExecutor on Windows +if __name__ == "__main__": + logging.basicConfig(level="INFO") + executor = Executor(max_workers=4, initializer=initializer) + aiorun.run(main(executor)) diff --git a/examples/overlapped/README.rst b/examples/overlapped/README.rst new file mode 100644 index 0000000..7a8777d --- /dev/null +++ b/examples/overlapped/README.rst @@ -0,0 +1,9 @@ +This is a basic test of plain socket connectivity and CTRL-C handling. +It should be possible to execute the run.py launcher which will start up +two subprocesses, one of which is a server and one is a client. And +when you press CTRL-C, everything should shut down cleanly without +tracebacks. + +If you execute run.py in a PyCharm run configuration, make sure to enable +the "Emulate terminal in output console" option. This will allow you +to press CTRL-C to trigger the KeyboardInterrupt. diff --git a/examples/overlapped/producer.py b/examples/overlapped/producer.py new file mode 100644 index 0000000..ec157a2 --- /dev/null +++ b/examples/overlapped/producer.py @@ -0,0 +1,27 @@ +import asyncio +from asyncio import StreamReader, StreamWriter + + +async def cb(reader: StreamReader, writer: StreamWriter): + try: + while True: + data = await reader.read(100) + print(data) + except asyncio.CancelledError: + writer.close() + await writer.wait_closed() + + +async def main(): + server = await asyncio.start_server(cb, host="127.0.0.1", port=12345) + async with server: + try: + await server.serve_forever() + except asyncio.CancelledError: + print("server cancelled") + + +try: + asyncio.run(main()) +except KeyboardInterrupt: + pass diff --git a/examples/overlapped/requirements.txt b/examples/overlapped/requirements.txt new file mode 100644 index 0000000..42d5101 --- /dev/null +++ b/examples/overlapped/requirements.txt @@ -0,0 +1 @@ +aiorun diff --git a/examples/overlapped/run.py b/examples/overlapped/run.py new file mode 100644 index 0000000..bacb7e6 --- /dev/null +++ b/examples/overlapped/run.py @@ -0,0 +1,35 @@ +# Example: load balancer + +import os +import sys +import shlex +import signal +from pathlib import Path +import subprocess as sp + +this_dir = Path(__file__).parent + + +def make_arg(cmdline): + return shlex.split(cmdline, posix=False) + + +# Start producer +s = f"{sys.executable} {this_dir / 'producer.py'}" +print(s) +producer = sp.Popen(make_arg(s)) + +# Start worker +s = f"{sys.executable} {this_dir / 'worker.py'}" +worker = sp.Popen(make_arg(s)) + +with producer, worker: + try: + producer.wait(100) + worker.wait(100) + except (KeyboardInterrupt, sp.TimeoutExpired): + producer.send_signal(signal.CTRL_C_EVENT) + worker.send_signal(signal.CTRL_C_EVENT) + + producer.wait(100) + worker.wait(100) diff --git a/examples/overlapped/worker.py b/examples/overlapped/worker.py new file mode 100644 index 0000000..d2f8527 --- /dev/null +++ b/examples/overlapped/worker.py @@ -0,0 +1,22 @@ +import asyncio + + +async def main(): + print("worker connecting") + reader, writer = await asyncio.open_connection("127.0.0.1", 12345) + print("worker connected") + try: + while True: + writer.write(b"blah") + await writer.drain() + print("sent data") + await asyncio.sleep(1.0) + except asyncio.CancelledError: + writer.close() + await writer.wait_closed() + + +try: + asyncio.run(main()) +except KeyboardInterrupt: + pass diff --git a/images/microservices.svg b/images/microservices.svg index a1aadf2..689abf7 100644 --- a/images/microservices.svg +++ b/images/microservices.svg @@ -1,390 +1,661 @@ + + + id="svg8" + inkscape:version="0.92.3 (2405546, 2018-03-11)" + sodipodi:docname="microservices.svg"> + + + + orient="auto" + inkscape:stockid="Arrow1Mstart"> + id="path13469" + inkscape:connector-curvature="0" /> + id="marker11937" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + transform="matrix(0.4,0,0,0.4,4,0)" + inkscape:connector-curvature="0" /> + orient="auto" + inkscape:stockid="Arrow1Mstart" + inkscape:collect="always"> + id="path11805" + inkscape:connector-curvature="0" /> + id="marker11653" + style="overflow:visible" + inkscape:isstock="true"> + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + orient="auto" + inkscape:stockid="Arrow1Mend"> + id="path10299" + inkscape:connector-curvature="0" /> + id="marker10195" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + orient="auto" + inkscape:stockid="Arrow1Mend" + inkscape:collect="always"> + id="path10093" + inkscape:connector-curvature="0" /> + id="marker9995" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + orient="auto" + inkscape:stockid="Arrow1Mend" + inkscape:collect="always"> + id="path5765" + inkscape:connector-curvature="0" /> + id="marker5727" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + orient="auto" + inkscape:stockid="Arrow1Mend" + inkscape:collect="always"> + id="path5685" + inkscape:connector-curvature="0" /> + id="Arrow1Mend" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + + + + orient="auto" + inkscape:stockid="Arrow1Lend"> + id="path5491" + inkscape:connector-curvature="0" /> + refX="0" + id="Arrow1Lstart" + style="overflow:visible" + inkscape:isstock="true"> + id="path5049" + d="M 0,0 5,-5 -12.5,0 5,5 Z" + style="fill:#0000ff;fill-opacity:1;fill-rule:evenodd;stroke:#0000ff;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.8,0,0,0.8,10,0)" + inkscape:connector-curvature="0" /> + id="Arrow1Mend-4" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + transform="matrix(-0.4,0,0,-0.4,-4,0)" /> + refX="0" + id="Arrow1Mend-4-5" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + style="fill:#0000ff;fill-opacity:1;fill-rule:evenodd;stroke:#0000ff;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" /> + refX="0" + id="Arrow1Mend-4-50" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + style="fill:#0000ff;fill-opacity:1;fill-rule:evenodd;stroke:#0000ff;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" /> + refX="0" + id="Arrow1Mend-4-8" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + style="fill:#0000ff;fill-opacity:1;fill-rule:evenodd;stroke:#0000ff;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" /> + + + + + + + refX="0" + id="marker11937-29" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(0.4,0,0,0.4,4,0)" /> + orient="auto" + inkscape:stockid="Arrow1Mend"> + id="path10299-4" /> + orient="auto" + inkscape:stockid="Arrow1Mend"> + id="path10299-1" /> + orient="auto" + inkscape:stockid="Arrow1Mend"> + id="path10299-2" /> + orient="auto" + inkscape:stockid="Arrow1Mend"> + id="path10299-3" /> + id="marker11653-6" + style="overflow:visible" + inkscape:isstock="true"> + id="marker11653-9" + style="overflow:visible" + inkscape:isstock="true"> + id="marker11653-5" + style="overflow:visible" + inkscape:isstock="true"> + id="marker11653-6-2" + style="overflow:visible" + inkscape:isstock="true"> + refX="0" + id="marker9995-2" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + style="fill:#28b200;fill-opacity:1;fill-rule:evenodd;stroke:#28b200;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + refX="0" + id="marker9995-2-7" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + style="fill:#28b200;fill-opacity:1;fill-rule:evenodd;stroke:#28b200;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + refX="0" + id="marker9995-2-73" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + + + + style="fill:#28b200;fill-opacity:1;fill-rule:evenodd;stroke:#28b200;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + + + + + + + refX="0" + id="marker9995-2-2-4-6" + style="overflow:visible" + inkscape:isstock="true" + inkscape:collect="always"> + + + + style="fill:#28b200;fill-opacity:1;fill-rule:evenodd;stroke:#28b200;stroke-width:1.00000003pt;stroke-opacity:1" + transform="matrix(-0.4,0,0,-0.4,-4,0)" + inkscape:connector-curvature="0" /> + + + @@ -398,475 +669,586 @@ + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(-13.971995,-64.76882)"> + + + + + + id="g4529" + transform="translate(80.962544,-21.166664)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715" /> A + x="39.277489" + id="tspan4522" + sodipodi:role="line">A + id="g4529-85" + transform="translate(80.962546,-5.291664)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-43" /> A + x="39.277489" + id="tspan4522-93" + sodipodi:role="line">A + id="g4529-5" + transform="translate(80.962546,10.583335)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-2" /> A + x="39.277489" + id="tspan4522-44" + sodipodi:role="line">A + id="g4529-56" + transform="translate(80.962546,26.458335)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-63" /> A + x="39.277489" + id="tspan4522-7" + sodipodi:role="line">A + id="g4529-6-28-6" + transform="translate(131.23355,-6.8791588)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-8-7-3" /> P + x="39.277489" + id="tspan4522-4-91-6" + sodipodi:role="line">P + id="g4529-6-28-67" + transform="translate(131.23355,11.641671)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-8-7-7" /> P + x="39.277489" + id="tspan4522-4-91-1" + sodipodi:role="line">P + id="g4529-4-7" + transform="translate(178.8588,-19.049995)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-4-7" /> B + x="39.277489" + id="tspan4522-0-6" + sodipodi:role="line">B + id="g4529-4-4" + transform="translate(178.8588,2.1166706)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-4-0" /> B + x="39.277489" + id="tspan4522-0-4" + sodipodi:role="line">B + id="g4529-4-5" + transform="translate(178.8588,23.283336)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-4-4" /> B + x="39.277489" + id="tspan4522-0-2" + sodipodi:role="line">B + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + id="g4529-6-0-5" + transform="translate(36.512519,-6.3500048)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-8-2-2" /> H + x="39.277489" + id="tspan4522-4-6-0" + sodipodi:role="line">H + id="g4529-6-0-5-6" + transform="translate(36.512519,12.170824)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-8-2-2-7" /> H + x="39.277489" + id="tspan4522-4-6-0-0" + sodipodi:role="line">H + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> h1.example.com + y="91.444275" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.23333311px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">h1.example.com h2.example.com + y="132.47665" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.23333311px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">h2.example.com p1.example.com + y="90.915504" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.23333311px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">p1.example.com p2.example.com + y="131.30032" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.23333311px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">p2.example.com + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + id="g4529-6-7-8" + transform="translate(-15.874997,2.1166596)"> + x="31.75" + height="13.229166" + width="21.166666" + id="rect3715-8-1-6" /> N + x="39.277489" + id="tspan4522-4-660-7" + sodipodi:role="line">N HTTP + x="1.6095028" + y="218.8334" + id="text17008" + transform="matrix(1.9461031,-0.2470617,0.16802884,0.49251579,0,0)">HTTP HTTP + x="22.193649" + y="202.28903" + id="text17008-7" + transform="matrix(1.9111804,0.44243201,-0.01300012,0.52022735,0,0)">HTTP load balancer + y="100.44127" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.23333311px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">load balancer + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccc" /> - + inkscape:connector-curvature="0" /> - + inkscape:connector-curvature="0" /> (scaling) - (scaling) + y="72.107292" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:3.52777767px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">(scaling) (scaling) - (scaling) + y="75.811462" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:3.52777767px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.26458332">(scaling) + + + M + + + + diff --git a/setup.py b/setup.py index fbd4113..26a9d5c 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ extras_require = { "dev": ["check-manifest", "colorama", "pygments", "twine", "wheel", "aiorun"], "test": ["pytest", "pytest-cov", "portpicker", "pytest-benchmark"], + "demo": ["aiohttp"], "doc": ["sphinx"], } extras_require["all"] = list(set().union(*extras_require.values()))