Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

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

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

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

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

Moscow Python Meetup
PRO

June 27, 2019
Tweet

More Decks by Moscow Python Meetup

Other Decks in Programming

Transcript

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

    View Slide

  2. Обо мне
    • Ведущий разработчик в ДомКлик
    • Python
    • Postgresql
    • Работа с персональными
    данными
    • Разработка MDM системы
    2

    View Slide

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

    View Slide

  4. Содержание
    • Немного про REST
    • Почему sanic хорош
    • Делаем API на sanic
    4

    View Slide

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

    View Slide

  6. Основные принципы REST
    • Отсутствие состояния
    • Использование HTTP методов
    • URI похож на структуру каталогов
    • Данные передаются в JSON
    6

    View Slide

  7. Схема микросервисов
    7
    Авторизация Профиль
    Биллинг
    Объявления
    JAVA Python
    Ruby
    GO
    Телефония
    Python

    View Slide

  8. Структура HTTP запроса и ответа
    8

    View Slide

  9. HTTP методы
    • GET - получить запись
    • POST - добавить (создать)
    • PUT - заменить запись на новую
    • PATCH - обновить запись
    • DELETE - удалить запись
    9

    View Slide

  10. Коды ответов
    • 2xx - успешно
    • 200 - запрос успешно обработан
    • 201 - запись создана
    • ……
    • 4хх - некорректные запросы
    • 400 - некорректный запрос
    • 404 - по данному URI ничего не найдено
    • ……..
    • 5хх - ошибки сервиса
    10
    Всего больше 50 кодов

    View Slide

  11. Работа web-сервиса
    11
    Принял запрос,
    десериализовал и
    свалидировал
    данные
    Бизнес-
    логика
    Сериализовать
    ответ и вернул
    его

    View Slide

  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
    Создаём хэдеры для ответа

    View Slide

  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"""Error 404: File not found
    Head back to dry land."""
    response = response_header.encode()
    if request_method == "GET":
    response += response_data
    client.send(response)
    client.close()
    break
    else:
    Простой вэб сервис своими руками:
    • Нет валидации данных
    • Нет полноценного парсера http
    • Нет нормального роутинга

    View Slide

  14. А может уже есть готовое?
    14

    View Slide

  15. Python web-frameworks
    • Sanic
    • Django
    • Flask
    • Tornado
    • И другие …..
    15

    View Slide

  16. Почему Sanic
    • Минималистичный
    • Быстрый
    • Как фласк, только асинхронный
    • Много готовых библиотек
    • Встроенный http сервер (не нужен UWSGI)
    16

    View Slide

  17. Что мы хотим
    • Проверять входящие данные
    • Обрабатывать ошибки
    • Swagger
    • Сделать удобный boilerplate
    • Тесты
    17

    View Slide

  18. План
    ➡ Запустить Sanic
    • Делаем API методы
    • Валидируем данные
    • Обработка ошибок
    • Сваггер
    • Тесты
    18

    View Slide

  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

    View Slide

  20. План
    ✓ Запустить Sanic
    ➡ Делаем методы
    • Валидируем данные
    • Обработка ошибок
    • Сваггер
    • Тесты
    20

    View Slide

  21. Делаем методы
    web_app = Sanic()
    @web_app.route(methods=[‘GET’], uri='/api/v1/users/')
    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/')
    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

    View Slide

  22. Оно работает!
    22

    View Slide

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

    View Slide

  24. План
    ✓ Запустить Sanic
    ✓ Делаем методы
    ➡ Валидируем данные
    • Обработка ошибок
    • Сваггер
    • Тесты
    24

    View Slide

  25. Валидация данных
    • schematics
    • marshmallow
    • cerberus
    • voluptuous
    • и т.д.
    25

    View Slide

  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

    View Slide

  27. Валидация данных работает…
    27

    View Slide

  28. План
    ✓ Запустить Sanic
    ✓ Делаем методы
    ✓ Валидируем данные
    ➡ Обработка ошибок
    • Сваггер
    • Тесты
    28

    View Slide

  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

    View Slide

  30. Обработка ошибок
    30

    View Slide

  31. План
    ✓ Запустить Sanic
    ✓ Делаем методы
    ✓ Валидируем данные
    ✓ Обработка ошибок
    ➡ Сваггер
    • Тесты
    31

    View Slide

  32. Open API & Swagger
    • OpenAPI представляет собой формализованную
    спецификацию для описания, создания,
    использования и визуализации веб-сервисов REST
    • Делает документацию «из кода»
    • Возможно сгенерировать код из сваггера

    https://editor.swagger.io/
    32

    View Slide

  33. 33

    View Slide

  34. Swaggers for Sanic
    • sanic-openapi
    - от разработчиков sanic
    • sanic-transmute
    - от других разработчиков
    34

    View Slide

  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

    View Slide

  36. sanic-openapi
    36

    View Slide

  37. sanic-openapi
    - не умеет class-based views
    - хочет свою примитивную модель данных
    - задваивает строки в сваггере, если в роуте 

    задано strict-slashes=False
    37

    View Slide

  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

    View Slide

  39. sanic-transmute
    39

    View Slide

  40. sanic-transmute
    40

    View Slide

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

    View Slide

  42. План
    ✓ Запустить Sanic
    ✓ Делаем методы
    ✓ Валидируем данные
    ✓ Обработка ошибок
    ✓ Сваггер
    ➡ Тесты
    42

    View Slide

  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

    View Slide

  44. План
    ✓ Запустить Sanic
    ✓ Делаем методы
    ✓ Валидируем данные
    ✓ Обработка ошибок
    ✓ Сваггер
    ✓ Тесты
    44

    View Slide

  45. Итог
    • Получился boilerplate на sanic
    • с валидацией данных
    • со сваггером
    • с тестами
    45

    View Slide

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

    View Slide