diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 28f7fa3..6735026 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -26,6 +26,12 @@ jobs: name: aiohttp network: data + - name: Benchmark AIOHTTP with Nginx + uses: ./.github/actions/benchmark + with: + name: aiohttp_nginx + network: data + - name: Benchmark Blacksheep uses: ./.github/actions/benchmark with: diff --git a/Dockerfile b/Dockerfile index b088bd8..f43d42d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.9-slim RUN apt-get update && \ - apt-get -y install --no-install-recommends build-essential + apt-get -y install --no-install-recommends build-essential nginx ENV PIP_DISABLE_PIP_VERSION_CHECK=1 diff --git a/frameworks/aiohttp_nginx/app.py b/frameworks/aiohttp_nginx/app.py new file mode 100644 index 0000000..24445da --- /dev/null +++ b/frameworks/aiohttp_nginx/app.py @@ -0,0 +1,82 @@ +import os +import argparse +import time +from uuid import uuid4 + +from aiohttp.web import ( + RouteTableDef, + Application, + Response, + json_response, + HTTPBadRequest, + HTTPUnauthorized, + run_app +) + +routes = RouteTableDef() + + +# first add ten more routes to load routing system +# ------------------------------------------------ +async def req_ok(request): + return Response(text='ok') + +for n in range(5): + routes.get(f"/route-{n}")(req_ok) + routes.get(f"/route-dyn-{n}/{{part}}")(req_ok) + + +# then prepare endpoints for the benchmark +# ---------------------------------------- +@routes.get('/html') +async def html(request): + """Return HTML content and a custom header.""" + content = "HTML OK" + headers = {'x-time': f"{time.time()}"} + return Response(text=content, content_type="text/html", headers=headers) + + +@routes.post('/upload') +async def upload(request): + """Load multipart data and store it as a file.""" + if not request.headers['content-type'].startswith('multipart/form-data'): + raise HTTPBadRequest() + + reader = await request.multipart() + data = await reader.next() + if data.name != 'file': + raise HTTPBadRequest() + + with open(f"/tmp/{uuid4().hex}", 'wb') as target: + target.write(await data.read()) + + return Response(text=target.name, content_type="text/plain") + + +@routes.put(r'/api/users/{user:\d+}/records/{record:\d+}') +async def api(request): + """Check headers for authorization, load JSON/query data and return as JSON.""" + if not request.headers.get('authorization'): + raise HTTPUnauthorized() + + return json_response({ + 'params': { + 'user': int(request.match_info['user']), + 'record': int(request.match_info['record']), + }, + 'query': dict(request.query), + 'data': await request.json(), + }) + + +app = Application() +app.add_routes(routes) + + +if __name__ == '__main__': + os.setuid(65534) + parser = argparse.ArgumentParser(description="aiohttp server example") + parser.add_argument('--path') + parser.add_argument('--port') + args = parser.parse_args() + run_app(app, path=args.path, port=args.port) diff --git a/frameworks/aiohttp_nginx/requirements.txt b/frameworks/aiohttp_nginx/requirements.txt new file mode 100644 index 0000000..8f9530d --- /dev/null +++ b/frameworks/aiohttp_nginx/requirements.txt @@ -0,0 +1 @@ +aiohttp == 3.8.1 diff --git a/frameworks/aiohttp_nginx/start.sh b/frameworks/aiohttp_nginx/start.sh new file mode 100755 index 0000000..a54cd4f --- /dev/null +++ b/frameworks/aiohttp_nginx/start.sh @@ -0,0 +1,54 @@ +#!/bin/sh + +python app.py --path=/tmp/example_1.sock & +python app.py --path=/tmp/example_2.sock & +python app.py --path=/tmp/example_3.sock & +python app.py --path=/tmp/example_4.sock & + +sleep 2 + +cat < /tmp/nginx.conf +error_log /dev/stdout info; + +events { + use epoll; + worker_connections 128; +} + +http { + access_log /dev/stdout; + server { + listen 8080; + client_max_body_size 4G; + server_name localhost; + + location / { + proxy_redirect off; + proxy_buffering off; + proxy_pass http://aiohttp; + } + } + + upstream aiohttp { + # fail_timeout=0 means we always retry an upstream even if it failed + # to return a good HTTP response + + # Unix domain servers + server unix:/tmp/example_1.sock fail_timeout=0; + server unix:/tmp/example_2.sock fail_timeout=0; + server unix:/tmp/example_3.sock fail_timeout=0; + server unix:/tmp/example_4.sock fail_timeout=0; + + # Unix domain sockets are used in this example due to their high performance, + # but TCP/IP sockets could be used instead: + # server 127.0.0.1:8081 fail_timeout=0; + # server 127.0.0.1:8082 fail_timeout=0; + # server 127.0.0.1:8083 fail_timeout=0; + # server 127.0.0.1:8084 fail_timeout=0; + } +} +EOF + + + +/usr/sbin/nginx -g 'daemon off;' -c /tmp/nginx.conf diff --git a/frameworks/aiohttp_nginx/test_aiohttp.py b/frameworks/aiohttp_nginx/test_aiohttp.py new file mode 100644 index 0000000..43fe6ef --- /dev/null +++ b/frameworks/aiohttp_nginx/test_aiohttp.py @@ -0,0 +1,78 @@ +import pathlib +import random +from importlib import import_module + +import pytest +from aiohttp.test_utils import TestClient, TestServer + + +@pytest.fixture(scope='module') +def app(): + return import_module('.aiohttp.app', package='frameworks').app + + +@pytest.fixture(scope='module') +async def aiohttp_client(app): + server = TestServer(app) + client = TestClient(server) + await client.start_server() + yield client + await client.close() + + +async def test_html(aiohttp_client, ts): + res = await aiohttp_client.get('/html') + assert res.status == 200 + assert res.headers.get('content-type').startswith('text/html') + header = res.headers.get('x-time') + assert header + assert float(header) >= ts + text = await res.text() + assert text == 'HTML OK' + + +async def test_upload(aiohttp_client): + res = await aiohttp_client.get("/upload") + assert res.status == 405 + + res = await aiohttp_client.post("/upload") + assert res.status == 400 + + res = await aiohttp_client.post("/upload", data={'file': open(__file__)}) + assert res.status == 200 + assert res.content_type.startswith('text/plain') + text = await res.text() + assert pathlib.Path(text).read_text('utf-8').startswith('import pathlib') + + +async def test_api(aiohttp_client): + rand = random.randint(10, 99) + + url = f"/api/users/{rand}/records/{rand}?query=test" + res = await aiohttp_client.get(url) + assert res.status == 405 + + res = await aiohttp_client.put(url, json={'foo': 'bar'}) + assert res.status == 401 + + res = await aiohttp_client.put(url, headers={'authorization': '1'}, json={'foo': 'bar'}) + assert res.status == 200 + assert res.headers.get('content-type') == 'application/json; charset=utf-8' + json = await res.json() + assert json['data'] + assert json['data'] == {'foo': 'bar'} + + assert json['query'] + assert json['query']['query'] == 'test' + + assert json['params'] == {'user': rand, 'record': rand} + + +async def test_routing(aiohttp_client): + rand = random.randint(10, 99) + for n in range(5): + res = await aiohttp_client.get(f"/route-{n}") + assert res.status == 200 + + res = await aiohttp_client.get(f"/route-dyn-{n}/{rand}") + assert res.status == 200