Готовь sanic летом

Готовь sanic летом

Алексей Чирков (ведущий разработчик, Domclick) @ Moscow Python Meetup 65

"Sanic — один из самых популярных web-фрейморков для Python. Попробуем разобраться почему он таким стал и как его готовить. В докладе будут рассмотрены основные принципы построения REST сервиса, затронуты вопросы валидации входных данных, сериализации результата.

Доклад будет интересен для разработчиков python уровня junior/middle, желающих получить практические навыки разрабтки асинхронных web-сервисов".
Видео: http://www.moscowpython.ru/meetup/65/sanic-for-summer/

53b0434aded1fb944ec3037c382158c1?s=128

Moscow Python Meetup

June 27, 2019
Tweet

Transcript

  1. Готовь Sanic летом Алексей Чирков, ДомКлик

  2. Обо мне • Ведущий разработчик в ДомКлик • Python •

    Postgresql • Работа с персональными данными • Разработка MDM системы 2
  3. О компании 3 • Ипотечная сделка онлайн • Маркетплейс недвижимости

    • Электронная регистрация и безналичные расчеты при приобретении квартиры • Python, Go, Ruby, Java, PostgreSQL, Mongo, ClickHouse, RabbitMQ, Docker, K8S • Больше 300 разработчиков и больше 50 различных команд • Микросервисная архитектура
  4. Содержание • Немного про REST • Почему sanic хорош •

    Делаем API на sanic 4
  5. Варианты взаимодействия микросервисов • REST • RPC • Очереди 5

  6. Основные принципы REST • Отсутствие состояния • Использование HTTP методов

    • URI похож на структуру каталогов • Данные передаются в JSON 6
  7. Схема микросервисов 7 Авторизация Профиль Биллинг Объявления JAVA Python Ruby

    GO Телефония Python
  8. Структура HTTP запроса и ответа 8

  9. HTTP методы • GET - получить запись • POST -

    добавить (создать) • PUT - заменить запись на новую • PATCH - обновить запись • DELETE - удалить запись 9
  10. Коды ответов • 2xx - успешно • 200 - запрос

    успешно обработан • 201 - запись создана • …… • 4хх - некорректные запросы • 400 - некорректный запрос • 404 - по данному URI ничего не найдено • …….. • 5хх - ошибки сервиса 10 Всего больше 50 кодов
  11. Работа web-сервиса 11 Принял запрос, десериализовал и свалидировал данные Бизнес-

    логика Сериализовать ответ и вернул его
  12. 12 def start(self): self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: self.socket.bind((self.host, self.port))

    except Exception as e: print(а"Error: Could not bind to port {port}") self.shutdown() sys.exit(1) self._listen() # Start listening for connections Открываем сокет def _listen(self): self.socket.listen(5) while True: (client, address) = self.socket.accept() client.settimeout(60) print(а"Received connection from {address}") threading.Thread(target=self._handle_client, args=(client, address)).start() Слушаем сокет def _generate_headers(self, response_code): header = '' if response_code == 200: header += 'HTTP/1.1 200 OK\n' elif response_code == 404: header += 'HTTP/1.1 404 Not Found\n' time_now = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime()) header += 'Date: {now}\n'.format(now=time_now) header += 'Server: Simple-Python-Server\n' header += 'Connection: close\n\n' return header Создаём хэдеры для ответа
  13. 13 import socket import sys import time import threading class

    WebServer(object): def __init__(self, port=8080): self.host = socket.gethostname().split('.')[0] # Default to any avialable network interface self.port = port self.content_dir = 'web' def start(self): self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: self.socket.bind((self.host, self.port)) except Exception as e: print("Error: Could not bind to port {port}".format(port=self.port)) self.shutdown() sys.exit(1) self._listen() # Start listening for connections def shutdown(self): try: s.socket.shutdown(socket.SHUT_RDWR) except Exception as e: pass def _generate_headers(self, response_code): header = '' if response_code == 200: header += 'HTTP/1.1 200 OK\n' elif response_code == 404: header += 'HTTP/1.1 404 Not Found\n' time_now = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime()) header += 'Date: {now}\n'.format(now=time_now) header += 'Server: Simple-Python-Server\n' header += 'Connection: close\n\n' return header def _listen(self): """ Listens on self.port for any incoming connections """ self.socket.listen(5) while True: (client, address) = self.socket.accept() client.settimeout(60) print("Recieved connection from {addr}".format(addr=address)) threading.Thread(target=self._handle_client, args=(client, address)).start() def _handle_client(self, client, address): PACKET_SIZE = 1024 while True: data = client.recv(PACKET_SIZE).decode() if not data: break request_method = data.split(' ')[0] if request_method == "GET" or request_method == "HEAD": file_requested = data.split(' ')[1] file_requested = file_requested.split('?')[0] if file_requested == "/": file_requested = "/index.html" filepath_to_serve = self.content_dir + file_requested print("Serving web page [{fp}]".format(fp=filepath_to_serve)) try: f = open(filepath_to_serve, 'rb') if request_method == "GET": # Read only for GET response_data = f.read() f.close() response_header = self._generate_headers(200) except Exception as e: print("File not found. Serving 404 page.") response_header = self._generate_headers(404) if request_method == "GET": # Temporary 404 Response Page response_data = b"""<html><body><center><h1>Error 404: File not found</h1></center> <p>Head back to <a href="/">dry land</a>.</p></body></html>""" response = response_header.encode() if request_method == "GET": response += response_data client.send(response) client.close() break else: Простой вэб сервис своими руками: • Нет валидации данных • Нет полноценного парсера http • Нет нормального роутинга
  14. А может уже есть готовое? 14

  15. Python web-frameworks • Sanic • Django • Flask • Tornado

    • И другие ….. 15
  16. Почему Sanic • Минималистичный • Быстрый • Как фласк, только

    асинхронный • Много готовых библиотек • Встроенный http сервер (не нужен UWSGI) 16
  17. Что мы хотим • Проверять входящие данные • Обрабатывать ошибки

    • Swagger • Сделать удобный boilerplate • Тесты 17
  18. План ➡ Запустить Sanic • Делаем API методы • Валидируем

    данные • Обработка ошибок • Сваггер • Тесты 18
  19. Sanic. Начало. from sanic.response import json from sanic import Sanic

    async def hello(request): return json({'hello': 'world'}) if __name__ == "__main__": web_app = Sanic() web_app.add_route(hello, '/hello') web_app.run(host="0.0.0.0", port=8080) 19
  20. План ✓ Запустить Sanic ➡ Делаем методы • Валидируем данные

    • Обработка ошибок • Сваггер • Тесты 20
  21. Делаем методы web_app = Sanic() @web_app.route(methods=[‘GET’], uri='/api/v1/users/<user_id>') async def get_user_by_id_method(request,

    user_id): user = await db_api.get_user_by_id(user_id) return json(user, status=200) @web_app.route(methods=[‘PUT’], uri='/api/v1/users/<user_id>') async def update_user_by_id_method(request, user_id): user = await db_api.update_user_by_id(user_id, request.json) return json(user, status=200) GET /api/v1/users/1234 21
  22. Оно работает! 22

  23. Но чего-то не хватает… 23

  24. План ✓ Запустить Sanic ✓ Делаем методы ➡ Валидируем данные

    • Обработка ошибок • Сваггер • Тесты 24
  25. Валидация данных • schematics • marshmallow • cerberus • voluptuous

    • и т.д. 25
  26. Schematics from schematics import Model, types class User(Model): user_id =

    types.IntType() age = types.IntType(min_value=0, max_value=99) name = types.StringType(required=True) phone = types.StringType() async def add_user_method(request): user = User(request.json, strict=True) user.validate() user = await db_api.add_user(user.to_native()) return json(user, status=201) 26
  27. Валидация данных работает… 27

  28. План ✓ Запустить Sanic ✓ Делаем методы ✓ Валидируем данные

    ➡ Обработка ошибок • Сваггер • Тесты 28
  29. Обработка ошибок from sanic.exceptions import NotFound, InvalidUsage from schematics.exceptions import

    BaseError async def update_user_by_id_method(request, user_id): user = User(request.json, strict=True) user.validate() user = db_api.update_user_by_id(user_id, user.to_native()) return json(user, status=200) def sanic_error_handler(status): async def custom_error_handler(request, exception): return json({'success': False, 'error': str(exception)}, status=status) return custom_error_handler web_app.error_handler.add(BaseError, sanic_error_handler(400)) web_app.error_handler.add(NotFound, sanic_error_handler(404)) web_app.error_handler.add(InvalidUsage, sanic_error_handler(400)) web_app.error_handler.add(Exception, sanic_error_handler(500)) 29
  30. Обработка ошибок 30

  31. План ✓ Запустить Sanic ✓ Делаем методы ✓ Валидируем данные

    ✓ Обработка ошибок ➡ Сваггер • Тесты 31
  32. Open API & Swagger • OpenAPI представляет собой формализованную спецификацию

    для описания, создания, использования и визуализации веб-сервисов REST • Делает документацию «из кода» • Возможно сгенерировать код из сваггера
 https://editor.swagger.io/ 32
  33. 33

  34. Swaggers for Sanic • sanic-openapi - от разработчиков sanic •

    sanic-transmute - от других разработчиков 34
  35. sanic-openapi from sanic_openapi import doc class UserModel: user_id = doc.Integer(description='User

    identifier') age = doc.Integer(description='User age') name = doc.String(description='User name') phone = doc.String(description='User phone') @doc.summary('Update user') @doc.consumes(UserModel, content_type="application/json", location="body") @doc.produces(UserModel) async def update_user_by_id_method(request, user_id): user_id = UserById({'user_id': user_id}).user_id user = User(request.json, strict=True) user.validate() user = db_api.update_user_by_id(user_id, user.to_native()) return json(user, status=200) 35
  36. sanic-openapi 36

  37. sanic-openapi - не умеет class-based views - хочет свою примитивную

    модель данных - задваивает строки в сваггере, если в роуте 
 задано strict-slashes=False 37
  38. sanic-transmute from sanic_transmute import describe from sanic_transmute import add_route @describe(paths="/users/{user_id}",

    methods="PUT", tags=['users'], parameter_descriptions={'user_id': 'id of user to update'}) async def update_user_by_id_method(request, user_id: int, user: InputUser) -> OutputUser: """Update user info by id""" user = await db_api.update_user_by_id(user_id, user.to_native()) return user 38
  39. sanic-transmute 39

  40. sanic-transmute 40

  41. sanic-transmute - не умеет class-based views ✓ требует аннотации типов

    ✓ валидирует данные на входе 41
  42. План ✓ Запустить Sanic ✓ Делаем методы ✓ Валидируем данные

    ✓ Обработка ошибок ✓ Сваггер ➡ Тесты 42
  43. Тесты 43 import pytest from main import web_app @pytest.mark.parametrize(('url', ),

    [ ('/api/v5/users/3', ), ('/api/v5/users/3/', ), ]) def test_user_get_by_id_returns_200(url): res = web_app.test_client.get(url) request, response = res assert response.status == 200 def test_user_get_by_incorrect_id_returns_400(): res = web_app.test_client.get('/api/v5/users/g') request, response = res assert response.status == 400 def test_incorrect_url_returns_404(): res = web_app.test_client.get('/hello_world') request, response = res assert response.status == 404
  44. План ✓ Запустить Sanic ✓ Делаем методы ✓ Валидируем данные

    ✓ Обработка ошибок ✓ Сваггер ✓ Тесты 44
  45. Итог • Получился boilerplate на sanic • с валидацией данных

    • со сваггером • с тестами 45
  46. Спасибо за внимание • Для связи: @pirat898 • Репозиторий https://github.com/pirat898/summer_sanic