DevGang
Авторизоваться

Создание приложения чата с помощью Serverless, WebSockets и Python 

Ближе к концу 2018 года Amazon выпустила поддержку WebSockets в API Gateway , и недавно платформа Serverless последовала его примеру . Благодаря тому, что WebSockets напрямую поддерживаются обоими этими инструментами, создание двунаправленного приложения в реальном времени стало проще, чем когда-либо прежде.

На момент написания этой статьи было несколько учебных пособий по использованию WebSockets с Serverless, но ни одно из них не использует Python в качестве основного языка или полностью описывает создание приложения WebSocket Serverless от начала до конца: эта статья пытается устранить этот пробел.

В этом руководстве мы создадим каноническое приложение для чата, которое использует Serverless Framework поверх AWS Lambda и API Gateway для внутреннего интерфейса вместе с простым клиентом Django. Для правильной оценки мы будем использовать django-uniauth для аутентификации пользователей и защиты наших запросов с помощью JSON Web Tokens (JWT) .

Шаг 1. Создание пользователя AWS

Первое, что мы сделаем, это создадим пользователя AWS для управления приложением. В качестве альтернативы вы можете использовать тот, который вы уже создали, но наличие отдельных пользователей для каждого приложения несколько лучше для безопасности (и организации).

Войдите в консоль управления IAM , перейдите в раздел «Пользователи» и нажмите «Добавить пользователя». Дайте ему имя пользователя и обязательно проверьте «Programmatic access», прежде чем продолжить.

Затем выберите «Attach existing policies directly» и предоставьте пользователю права «AdministratorAccess».

Мы используем эту политику для простоты, но для реального приложения вы должны выбрать именно те разрешения, которые вам требуются.

Если хотите, добавьте пользователю теги, в противном случае продолжайте, пока пользователь не будет создан.

Загрузите CSV-файл с учетными данными и сохраните его в безопасном месте или, по крайней мере, запишите ключ доступа и секретный ключ.

Теперь добавьте запись для этого в ~/.aws/credentials

[serverless-chat-tutorial] 
aws_access_key_id = < YOUR_ACCESS_KEY > 
aws_secret_access_key = < YOUR_SECRET_KEY >

Шаг 2: установка + настройка Serverless

Как и во всех проектах Python, рекомендуется использовать виртуальную среду для изоляции наших зависимостей. Создайте каталог для приложения:

mkdir serverless-chat-tutorial
cd serverless-chat-tutorial

Затем создайте и активируйте виртуальную среду:

virtualenv -p python3 venv
. venv/bin/activate

Теперь давайте установим Serverless:

sudo npm install -g serverless

И экспортируйте необходимые переменные среды:

export AWS_PROFILE=serverless-chat-tutorial
export AWS_REGION=us-east-1

Мне нравится настраивать мою виртуальную среду для экспорта всякий раз, когда среда активируется, добавляя эти строки в venv/bin/activate скрипт

Шаг 3: Напишите функцию Ping без сервера

Давайте познакомимся с Serverless Framework, написав простую функцию ping, которая просто возвращает строку вызывающей стороне.

Сначала создайте папку для бэкэнда:

mkdir backend
cd backend

Затем попросите Serverless создать для нас шаблонный код:

serverless create --template aws-python3 --name serverless-chat

Эта команда создает шаблон handler.py и serverless.yml для службы под названием «serverless-chat». Он также создает  .gitignore в текущем каталоге.

Игнорируя комментарии, исходный serverless.yml файл будет выглядеть так:

service: serverless-chat

provider:
  name: aws
  runtime: python3.7

functions:
  hello:
    handler: handler.hello

Этот файл отвечает за настройку того, как Serverless будет развертывать вашу службу, включая функции AWS Lambda, и, в конечном итоге, любые ресурсы, необходимые вашей службе.

Файл handler.py будет выглядеть следующим образом:

import json

def hello(event, context):
   body = {
       "message": "Go Serverless v1.0! Your function executed successfully!",
       "input": event
   }
   response = {
       "statusCode": 200,
       "body": json.dumps(body)
   }
   return response

Как и следовало ожидать, этот файл на самом деле будет содержать код для развернутых функций лямбда AWS. Вы можете разделить методы на отдельные файлы, если хотите, но здесь мы сохраним все в handler.py для простоты.

Эти файлы шаблонов вместе составляют простую функцию Hello World, которая может быть немедленно развернута. Давай сделаем это!

serverless deploy

Если все пойдет хорошо, вы увидите вывод, который выглядит следующим образом:

Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service serverless-chat.zip file to S3 (307 B)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
................
Serverless: Stack update finished...
Service Information
service: serverless-chat
stage: dev
region: us-east-1
stack: serverless-chat-dev
resources: 5
api keys:
  None
endpoints:
  None
functions:
  hello: serverless-chat-dev-hello
layers:
  None

И вы можете вызвать вашу функцию с помощью команды:

serverless invoke -f hello

Которая должна вернуть что-то вроде:

{
    "statusCode": 200,
    "body": "{\"message\": \"Go Serverless v1.0! Your function executed successfully!\", \"input\": {}}"
}

Отлично, он жив! Но прямо сейчас единственный способ вызвать функцию - это вызов через командную строку: если мы собираемся создать API, он должен быть доступен через публичный URL. Давайте изменим это.

В handler.py, измените имя функции «hello» на «ping», и измените тело, чтобы просто вернуть «PONG!»:

import json
def ping(event, context):
   """
   Проверка работоспособности, которая возвращает отправителю сообщение «PONG».
   """
   response = {
       "statusCode": 200,
       "body": "PONG!"
   }
   return response

В serverless.yml, замените функцию «hello», на «ping», и используйте новую функцию обработчика. Также добавьте событие для запуска функции при запросе URL пути /ping:

service: serverless-chat

provider:
   name: aws
   runtime: python3.7

functions:
   ping:
       handler: handler.ping
       events:
           - http:
               path: ping
               method: get

Теперь отправим изменения в serverless:

serverless deploy

В конце вывода развертывания вы должны увидеть список общедоступных url адресов, предоставляемых вашей службой. Это будет похоже на следующее:

...
endpoints:
  GET - https://**********.execute-api.us-east-1.amazonaws.com/dev/ping
...

Вы можете посетить этот URL с помощью браузера или протестировать его с помощью curl или любого другого инструмента командной строки, который вы предпочитаете.

curl "https://**********.execute-api.us-east-1.amazonaws.com/dev/ping"
>> PONG!

Шаг 4: Добавим логирование

В конечном итоге нам понадобится отладить наш API. Использование безсерверной инфраструктуры делает это немного сложнее, поскольку нет сервера для входа и тестирования, но это все же возможно с AWS CloudWatch .

Любые операторы печати или методы Logger внутри лямбда-функции будут генерировать запись журнала в AWS CloudWatch. Использование методов Logger по умолчанию даст нам отметку времени и уровень фильтрации, поэтому мы будем использовать их в этом руководстве.

Вернитесь handler.py и добавьте код для импорта модуля logging, создайте Logger и используйте его в функции ping.

import json
import logging

logger = logging.getLogger("handler_logger")
logger.setLevel(logging.DEBUG)

def ping(event, context):
   """
   Проверка работоспособности, которая возвращает отправителю сообщение «PONG».
   """
   logger.info("Ping requested.")
   response = {
       "statusCode": 200,
       "body": "PONG!"
   }
   return response

Повторно разверните серверную часть, затем вызовите функцию ping.

serverless deploy
...
curl "< YOUR_PING_ENDPOINT >"

Теперь войдите в CloudWatch и перейдите в Журналы. Вы должны увидеть, что была создана новая группа журналов для функции AWS Lambda ping.

Щелкните по этой группе журналов, затем по последнему потоку журналов, и вы увидите журналы, сгенерированные во время самого последнего вызова функции, включая новую запись «Ping requested».

Это будет одним из ваших лучших ресурсов при отладке более сложных функций в AWS Lambda / Serverless.

Шаг 5: Добавьте базу данных + зависимости Python

Мы почти готовы начать писать наш WebSocket API, но сначала нам нужна база данных. В этом руководстве мы будем использовать DynamoDB , базу данных NoSQL, которую легко настроить.

Нам нужна таблица для отслеживания наших идентификаторов подключения WebSocket, а другая - для хранения сообщений, отправленных в чатах. Давайте сначала создадим таблицу Connections.

Войдите в DynamoDB , затем нажмите «Create table». Присвойте таблице имя и введите «ConnectionID» в качестве ключа раздела.

После завершения загрузки таблицы Connections снова нажмите «Create table». Дайте ему имя, и на этот раз введите «Room» как ключ разделения и «Index» как ключ сортировки чисел.

После создания таблицы, нажмите на вкладку «Items», если хотите проверить столбцы первичного ключа.

Сейчас он пуст, но вы можете вернуться на эту вкладку, чтобы увидеть элементы в таблице по мере их ввода.

Теперь нам нужно настроить наш внутренний код, чтобы иметь возможность взаимодействовать с базой данных. В Python вы взаимодействуете со службами Amazon через модуль boto . На самом деле в Amazon Lambda уже установлена boto ​​по умолчанию, но на момент написания этой статьи предустановленная версия устарела и нам не хватает ключевых функций, поэтому нам все равно нужно будет указать свои собственные.

Сначала откройте serverless.yml и добавьте iamRoleStatement разрешающие действия DynamoDB (измените «us-east-1», если вы находитесь в другом регионе).

provider:
   name: aws
   runtime: python3.7
   iamRoleStatements:
       - Effect: Allow
         Action:
             - "dynamodb:PutItem"
             - "dynamodb:GetItem"
             - "dynamodb:UpdateItem"
             - "dynamodb:DeleteItem"
             - "dynamodb:BatchGetItem"
             - "dynamodb:BatchWriteItem"
             - "dynamodb:Scan"
             - "dynamodb:Query"
         Resource:
             - "arn:aws:dynamodb:us-east-1:*:*"

Чтобы указать наши зависимости python, мы будем использовать плагин «serverless-python-needs», который будет читать файл requirements.txt в корневом каталоге развертывания и устанавливать его для нас, прежде чем отправлять функцию обработчика в AWS Lambda.

Сначала установите плагин локально, запустив:

serverless plugin install -n serverless-python-requirements

Обратите внимание, что он также будет создавать папки  package.json, package-lock.json и node_modules в текущей директории.

Затем добавьте следующие строки serverless.yml (первые две, возможно, уже были добавлены вышеуказанной командой):

plugins:
    - serverless-python-requirements
custom:
    pythonRequirements:
        dockerizePip: true
        noDeploy: []

Первые две строки указывают serverless использовать плагин python-requirements, а остальные строки, как мы его настраиваем.

Нам это нужно, dockerizePip потому что обработчики Amazon Lambda работают с определенной версией Linux, на которой мы, возможно, еще не работаем. Таким образом, все зависимости устанавливаются в экземпляре Docker перед отправкой в ​​AWS. (Обратите внимание, что для этого требуется загрузить и установить Docker, если вы этого еще не сделали).

Параметр noDeploy говорит Amazon Lambda использовать нашу версию каких - либо пакетов, которую мы указываем, если они уже установлена. Обновленный файл serverless.yml должен выглядеть следующим образом:

service: serverless-chat

provider:
   name: aws
   runtime: python3.7
   iamRoleStatements:
       - Effect: Allow
         Action:
             - "dynamodb:PutItem"
             - "dynamodb:GetItem"
             - "dynamodb:UpdateItem"
             - "dynamodb:DeleteItem"
             - "dynamodb:BatchGetItem"
             - "dynamodb:BatchWriteItem"
             - "dynamodb:Scan"
             - "dynamodb:Query"
         Resource:
             - "arn:aws:dynamodb:us-east-1:*:*"

plugins:
   - serverless-python-requirements

custom:
   pythonRequirements:
       dockerizePip: true
       noDeploy: []

functions:
   ping:
       handler: handler.ping
       events:
           - http:
               path: ping
               method: get

Наконец, установите пакеты boto3 и botocore:

pip install boto3 botocore

И создайте файл requirements.txt, перечисляющий их как зависимости:

pip freeze > requirements.txt

На данный момент мой requirements.txt выглядит так (у вас могут быть другие номера версий):

boto3==1.9.130
botocore==1.12.130
... (Less important packages) ...

И мой внутренний каталог выглядит так:

.
├── .gitignore
├── .serverless/
├── handler.py
├── node_modules/
├── package-lock.json
├── package.json
├── requirements.txt
└── serverless.yml

Теперь мы должны иметь возможность читать и обновлять базу данных в нашем обработчике Python. Давайте изменим метод ping, чтобы доказать это.

Импортируйте boto3 и создайте ресурс DynamoDB в верхней части файла, затем напишите некоторый код для доступа к таблице сообщений и добавьте в нее элемент. В этом вам может помочь документация по boto3.

Обновленный handler.py должен выглядеть примерно так:

import boto3
import json
import logging
import time

logger = logging.getLogger("handler_logger")
logger.setLevel(logging.DEBUG)
dynamodb = boto3.resource("dynamodb")

def ping(event, context):
   """
   Api проверки работоспособности, которая возвращает отправителю сообщение «PONG»
   """
   logger.info("Ping requested.")

   # TESTING: Убедитесь, что база данных работает
   table = dynamodb.Table("serverless-chat_Messages")
   timestamp = int(time.time())
   table.put_item(Item={"Room": "general", "Index": 0,
           "Timestamp": timestamp, "Username": "ping-user",
           "Content": "PING!"})
   logger.debug("Item added to the database.")

   response = {
       "statusCode": 200,
       "body": "PONG!"
   }
   return response

Обратите внимание, что я добавил несколько дополнительных ключей к элементу, помещенному в базу данных:

  • Отметка времени: отслеживает, когда сообщение было создано
  • Имя пользователя: имя пользователя, создавшего сообщение
  • Содержание: фактический текст в сообщении

Теперь заново разверните и вызовите функцию ping:

serverless deploy
...
curl "< YOUR_PING_ENDPOINT >"

Если вы хотите проверить данные в DynamoDB, перейдите в таблицу «Сообщения» и нажмите на вкладку «Элементы», вы должны увидеть только что созданное сообщение.

Если нет, посетите CloudWatch, как описано в шаге 4, чтобы помочь вам отладить то, что пошло не так.

Шаг 6: Напишите обработчики WebSocket

Мы наконец добираемся до хороших вещей! С установкой в ​​стороне, пришло время написать основные обработчики WebSocket.

Наш API будет поддерживать четыре функции: connectionManager для обработки подключения и отключения от WebSocket, sendMessage для отправки сообщения в чат-комнату, getRecentMessages для получения самых последних сообщений чата (используемых при первом подключении) и defaultMessage для изящной обработки запросов, не поддерживаемых API.

Откройте handler.py и создайте функцию для менеджера соединений:

def connection_manager(event, context):
   """
   Функция подключения и отключения для веб-сокета.
   """
   connectionID = event["requestContext"].get("connectionId")
   
   if event["requestContext"]["eventType"] == "CONNECT":
       logger.info("Connect requested")
       
   elif event["requestContext"]["eventType"] == "DISCONNECT":
       logger.info("Disconnect requested")
       
   else:
       logger.error("Connection manager received unrecognized eventType.")

Amazon Lambda передает данные события через аргумент события, который мы можем использовать для доступа к connectionId. Мы также можем получить доступ к eventType, который говорит нам, подключается ли пользователь или отключается от WebSocket.

Затем напишите код для добавления connectionId в таблицу подключений при подключении и удалите его при отключении.

def connection_manager(event, context):
   ...   
   connectionID = event["requestContext"].get("connectionId")
   
   if event["requestContext"]["eventType"] == "CONNECT":
       logger.info("Connect requested")

       # Добавить connectionID в базу данных
       table = dynamodb.Table("serverless-chat_Connections")
       table.put_item(Item={"ConnectionID": connectionID})

   elif event["requestContext"]["eventType"] == "DISCONNECT":
       logger.info("Disconnect requested")

       # Удалить идентификатор соединения из базы данных
       table = dynamodb.Table("serverless-chat_Connections")
       table.delete_item(Key={"ConnectionID": connectionID})
   ...

Наконец, функция должна возвращать словарь ответов при каждом выходе, как это делает наша функция ping. Нам нужно будет использовать ее в нескольких местах, поэтому я создал вспомогательную функцию, которая выполняет упаковку для меня.

def _get_response(status_code, body):
   if not isinstance(body, str):
       body = json.dumps(body)
   return {"statusCode": status_code, "body": body}

def connection_manager(event, context):   ...
   
   if event["requestContext"]["eventType"] == "CONNECT":
       logger.info("Connect requested")

       # Добавить connectionID в базу данных
       table = dynamodb.Table("serverless-chat_Connections")
       table.put_item(Item={"ConnectionID": connectionID})
       return _get_response(200, "Connect successful.")

   elif event["requestContext"]["eventType"] == "DISCONNECT":
       logger.info("Disconnect requested")

       # Удалить идентификатор соединения из базы данных
       table = dynamodb.Table("serverless-chat_Connections")
       table.delete_item(Key={"ConnectionID": connectionID})
       return _get_response(200, "Disconnect successful.")

   else:
       logger.error("Connection manager received unrecognized eventType '{}'")
       return _get_response(500, "Unrecognized eventType.")

Далее, давайте создадим обработчик для отправки сообщения. Первое, что нам нужно сделать, это проанализировать данные сообщения от клиента. Клиент должен предоставить содержимое сообщения, а сейчас ему также необходимо указать имя пользователя отправителя.

def _get_body(event):
   try:
       return json.loads(event.get("body", ""))
   except:
       logger.debug("event body could not be JSON decoded.")
       return {}

def send_message(event, context):
   """
   Когда сообщение отправлено в сокет, отправляем его всем соединениям.
   """
   logger.info("Message sent on WebSocket.")

   # Убедитесь, что все обязательные поля были предоставлены
   body = _get_body(event)
   for attribute in ["username", "content"]:
       if attribute not in body:
           logger.debug("Failed: '{}' not in message dict."\
                   .format(attribute))
           return _get_response(400, "'{}' not in message dict"\
                   .format(attribute))

Прежде чем обновить WebSocket слушателей, мы должны сохранить новое сообщение в базе данных. В таблице Message в нашем проекте есть поля для Username, Content, Room, Index, и Timestamp. Username и Content предоставляются нам клиентом. Сейчас все будут публиковать сообщения одинаково в Room, поэтому мы можем установить для них некоторую постоянную строку (если мы хотим поддерживать частные чаты, клиент также отправит в сообщение Room для публикации). Мы будем вручную вычислять Index, запрашивая последнее Message в базе данных, и увеличивая его индекс на единицу. Наконец, мы можем легко рассчитать, Timestamp используя модуль time.

def send_message(event, context):   ...
   
   # Получить индекс следующего сообщения
   response = table.query(KeyConditionExpression="Room = :room",
           ExpressionAttributeValues={":room": "general"},
           Limit=1, ScanIndexForward=False)
   items = response.get("Items", [])
   index = items[0]["Index"] + 1 if len(items) > 0 else 0

   # Добавить новое сообщение в базу данных
   timestamp = int(time.time())
   username = body["username"]
   content = body["content"]
   table.put_item(Item={"Room": "general", "Index": index,
           "Timestamp": timestamp, "Username": username,
           "Content": content})

Наконец, сообщение должно быть отправлено всем слушателям, подключенным к WebSocket. Сначала мы запрашиваем все идентификаторы соединений в таблице соединений, затем отправляем сообщение (без всех ненужных данных) каждому из этих соединений. Чтобы на самом деле отправить сообщение, мы можем использовать post_to_connection метод в boto3 API Gateway Management.

def _send_to_connection(connection_id, data, event):
   gatewayapi = boto3.client("apigatewaymanagementapi",
           endpoint_url = "https://" + event["requestContext"]["domainName"] +
                   "/" + event["requestContext"]["stage"])
   return gatewayapi.post_to_connection(ConnectionId=connection_id,
           Data=json.dumps(data).encode('utf-8'))


def send_message(event, context):
   
   ...
   
   # Получить все текущие соединения
   table = dynamodb.Table("serverless-chat_Connections")
   response = table.scan(ProjectionExpression="ConnectionID")
   items = response.get("Items", [])
   connections = [x["ConnectionID"] for x in items if "ConnectionID" in x]

   # Отправить данное сообщения всем соединениям
   message = {"username": username, "content": content}
   logger.debug("Broadcasting message: {}".format(message))
   data = {"messages": [message]}
   for connectionID in connections:
       _send_to_connection(connectionID, data, event)
   return _get_response(200, "Message sent to all connections.")

Теперь давайте напишем обработчик для получения 10 самых последних сообщений. Для этого мы запрашиваем первые 10 элементов в обратном порядке индексов, что достигается установкой Limit=10 и ScanIndexForward=False. Однако это дает нам необработанные элементы Message в обратном хронологическом порядке, поэтому перед отправкой их клиенту мы переворачиваем список и удаляем элементы, чтобы просто включить username и content.

def get_recent_messages(event, context):
   """
   Вернуть 10 последних сообщений чата.
   """
   logger.info("Retrieving most recent messages.")
   connectionID = event["requestContext"].get("connectionId")

   # Получить 10 самых последних сообщений из чата
   table = dynamodb.Table("serverless-chat_Messages")
   response = table.query(KeyConditionExpression="Room = :room",
           ExpressionAttributeValues={":room": "general"},
           Limit=10, ScanIndexForward=False)
   items = response.get("Items", [])

   # Извлечение соответствующих данных и порядок в хронологическом порядке
   messages = [{"username": x["Username"], "content": x["Content"]}
           for x in items]
   messages.reverse()

   # Отправьте их клиенту, который попросил об этом
   data = {"messages": messages}
   _send_to_connection(connectionID, data, event)
   return _get_response(200, "Sent recent messages.")

Наконец, мы напишем обработчик сообщений по умолчанию, который просто регистрирует событие и возвращает код состояния 400. Я также взял тест базы данных из обработчика функции ping и обновил его, чтобы использовать вспомогательную функцию _get_response.

def default_message(event, context):
   """
   Ошибка возврата при получении неизвестного действия WebSocket.
   """
   logger.info("Unrecognized WebSocket action received.")
   return _get_response(400, "Unrecognized WebSocket action.")

def ping(event, context):
   """
   Api проверки работоспособности, которая возвращает отправителю сообщение «PONG»
   """
   logger.info("Ping requested.")
   return _get_response(200, "PONG!")

После написания всех функций-обработчиков нам просто нужно правильно настроить их для работы без сервера. Откройте serverless.yml и добавьте следующие строки в provider:

provider:
   ...
   websocketApiName: serverless-chat-api
   websocketApiRouteSelectionExpression: $request.body.action

Первая строка используется внутренне API-шлюзом при настройке конечной точки WebSocket. Второй указывает, какой ключ в словаре данных используется для определения «маршрута», который диктует функцию API, которую нужно запустить (подробнее об этом позже).

Нам также нужно дать разрешение Serverless на управление соединениями WebSocket:

provider:
   ...
   iamRoleStatements:
       - Effect: Allow
         Action:
             - "execute-api:ManageConnections"
         Resource:
             - "arn:aws:execute-api:*:*:**/@connections/*"
       ...

Наконец, нам нужно связать наши обработчики функций с маршрутами WebSocket, как показано ниже:

functions:
    ...
    defaultMessage:
        handler: handler.default_message
        events:
            - websocket:
                route: $default
    ...

Обратите внимание, что $connect, $disconnect и $default являются специальными маршрутами, и для них требуется предваряющий «$», если вы хотите соответствующие встроенные события WebSocket. Никакие другие маршруты не нуждаются в «$».

Окончательный serverless.yml файл должен выглядеть следующим образом:

service: serverless-chat

provider:
   name: aws
   runtime: python3.7
   websocketApiName: serverless-chat-api
   websocketApiRouteSelectionExpression: $request.body.action
   iamRoleStatements:
       - Effect: Allow
         Action:
             - "execute-api:ManageConnections"
         Resource:
             - "arn:aws:execute-api:*:*:**/@connections/*"
       - Effect: Allow
         Action:
             - "dynamodb:PutItem"
             - "dynamodb:GetItem"
             - "dynamodb:UpdateItem"
             - "dynamodb:DeleteItem"
             - "dynamodb:BatchGetItem"
             - "dynamodb:BatchWriteItem"
             - "dynamodb:Scan"
             - "dynamodb:Query"
         Resource:
             - "arn:aws:dynamodb:us-east-1:*:*"

plugins:
   - serverless-python-requirements

custom:
   pythonRequirements:
       dockerizePip: true
       noDeploy: []

functions:
   connectionManager:
       handler: handler.connection_manager
       events:
           - websocket:
               route: $connect
           - websocket:
               route: $disconnect
   defaultMessage:
       handler: handler.default_message
       events:
           - websocket:
               route: $default
   getRecentMessages:
       handler: handler.get_recent_messages
       events:
           - websocket:
               route: getRecentMessages
   sendMessage:
       handler: handler.send_message
       events:
           - websocket:
               route: sendMessage
   ping:
       handler: handler.ping
       events:
           - http:
               path: ping
               method: get

API WebSockets наконец готов. Переустановка:

serverless deploy

И пересмотреть выведенные конечные точки. Вы должны увидеть, что новая функция появилась под вашей функцией ping:

...
endpoints:
  GET - < YOUR_PING_ENDPOINT >
  wss://**********.execute-api.us-east-1.amazonaws.com/dev
...

Вы можете проверить это любым инструментом, который пожелаете. Простым вариантом является WebSocket cat, который можно установить с помощью следующей команды:

npm install -g wscat

После установки подключитесь к конечной точке WebSocket:

wscat -c < YOUR_WEBSOCKET_ENDPOINT >

Затем отправьте запросы, используя «action» в качестве значения маршрута:

{"action": "sendMessage", "username": "test-websocket-user", "content": "Testing the Websocket API."}

Как и раньше, вы можете использовать консоли CloudWatch и DynamoDB для помощи в отладке, если что-то пойдет не так.

Шаг 7: Написать клиент

Теперь, когда бэкэнд полностью функционален, мы можем написать простой клиент для взаимодействия с ним. В этом уроке будет использоваться Django , популярный веб-фреймворк Python.

(Примечание. Основное внимание в этом руководстве уделяется работе без сервера и WebSockets, а не по созданию клиента, поэтому я буду предполагать некоторое знакомство с Django и изучение любых компонентов, не относящихся к WebSocket.)

Сначала установите Django:

pip install Django

Затем перейдите в каталог, содержащий backend, и запустите новый проект Django.

django-admin startproject project
mv project client

Затем создайте новое приложение «чат» в проекте.

cd client
python manage.py startapp chat

Мой репозиторий теперь выглядит так:

.
├── backend/
├── client/
    ├── chat/
        ├── __init__.py
        ├── admin.py
        ├── app.py
        ├── migrations/
        ├── models.py
        ├── tests.py
        └── views.py
    ├── manage.py
    └── project/
        ├── __init__.py
        ├── settings.py
        ├── urls.py
        └── wsgi.py
└── venv/

В каталоге client внесите следующие изменения:

В project/settings.py добавьте «chat» к настройке INSTALLED_APPS.

INSTALLED_APPS = [
    ...
    'chat',
]

В project/urls.py, включите URL-адреса приложения чата в пустой путь.

...
from django.urls import include, path

urlpatterns = [
   ...
   path('', include('chat.urls')),
]

Перейдите в каталог приложения чата.

cd chat

Создайте каталоги templates и chat внутри него.

mkdir templates
mkdir templates/chat

Внутри каталога templates/chat создайте файл index.html для приложения.

< !DOCTYPE html>
< html>
   < head>
       {% load static %}
       < meta charset="utf-8"/>
       < meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"/>
       < title>Serverless Chat< /title>
       < link rel="shortcut icon" href="favicon.ico"/>
       < link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" type="text/css" crossorigin="anonymous"/>
       < link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css" type="text/css" crossorigin="anonymous"/>
       < link rel="stylesheet" href="{% static 'chat/style.css' %}" type="text/css"/>
   < /head>
   < body>
       

Welcome to the

Serverless Chatroom

Catch up on the conversation below!

-- No messages --
< /body> < /html>

Из каталога приложения чата создайте каталог static и каталог chat внутри него.

mkdir static
mkdir static/chat

Внутри каталога static/chat создайте файл style.css для стилизации страницы индекса.

body {
   background: #4c5667;
   height: 100%;
}
html {
   height: 100%;
}
.btn-primary {
   background-color: #039cfd;
   border-color: #039cfd;
}
#comments-icon {
   padding-right: 16px;
}
#footer-container {
   bottom: 0px;
   left: 50%;
   margin-left: -35%;
   position: absolute;
   width: 70%
}
#header-bar {
   background: #dcdfe2;
   padding: 2rem 1rem;
}
#header-bar h1 {
   display: inline-block;
}
#header-text-first {
   padding-right: 10px;
}
#main-container {
   height: calc(100% - 262px);
   padding-left: 15%;
   padding-right: 15%;
   width: 100%;
}
#main-wrapper {
   background: #f4f6fa;
   height: 100%;
   overflow: auto;
}
#main-wrapper .container {
   padding-bottom: 30px;
}
#message-container {
   display: block;
   height: 100%;
   overflow-y: auto;
}
#post-bar {
   flex: 1;
   width: 100%;
}
#post-btn {
   margin-left: 15px;
}
#post-container {
   background-color: #dcdfe2
}
#results p b {
   padding-right: 8px;
}
@media (max-width: 768px) {
   #search-actions {
       padding-top: 10px;
       padding-left: 46px;
   }
}
#search-actions button {
   margin-right: 5px;
   width: 80px;
}

Теперь нам просто нужно создать и направить представление для отображения нового шаблона. Вернувшись в каталог приложения чата, добавьте следующее в views.py:

from django.shortcuts import render

def index(request):
   return render(request, "chat/index.html")

Наконец, создайте файл с именем urls.py и направьте представление индекса к пустому пути.

from django.urls import path
from . import views

urlpatterns = [
   path('', views.index, name="index")
]

Вернитесь в каталог client, примените все начальные миграции и запустите сервер Django.

cd ..
python manage.py migrate
python manage.py runserver

Зайдите по адресу localhost:8000 в браузер, и вы должны увидеть страницу.

Конечно, он на самом деле ни к чему не подключается, и кнопка «Пуск» внизу ничего не делает в данный момент. Время это исправить.

Из каталога приложения чата создайте новый файл с именем custom.js в каталоге static/chat. Этот файл будет содержать наш код взаимодействия с WebSocket для клиента.

Начните с создания функции для настройки WebSocket и инициализации переменной сокета. Javascript имеет встроенный API WebSocket, но мне нравится использовать эту библиотеку WebSocket для повторного подключения страниц, которые никогда не хотят закрывать соединение WebSocket.

var socket;

// Подключитесь к WebSocket и настройте прослушиватели
function setupWebSocket() {
   socket = new ReconnectingWebSocket("< YOUR_WEBSOCKET_ENDPOINT >");
}

Теперь мы определяем функции обратного вызова, которые вызываются для определенных событий WebSocket. Например, когда WebSocket первоначально открыт, будет вызвана функция socket.onopen.

Для нашей функции обратного вызова onopen мы хотим вызвать функцию API getRecentMessages, чтобы изначально заполнить список сообщений. Ключом маршрута для нашего API является «action», поэтому мы создаем словарь, устанавливающий «action» для «getRecentMessages», сериализуем его с помощью JSON, а затем отправляем в сокет.

function setupWebSocket() {
   socket = new ReconnectingWebSocket(...);

   socket.onopen = function(event) {
       data = {"action": "getRecentMessages"};
       socket.send(JSON.stringify(data));
   };
}

Функция обратного вызова socket.onmessage вызывается всякий раз, когда клиент получает данные о WebSocket. Для нашего приложения мы просто хотим десериализовать данные, а затем отобразить новые сообщения в списке сообщений.

Некоторая дополнительная логика включена для отображения «You» вместо имени пользователя для сообщений, отправленных текущим пользователем. Поскольку у пользователей еще нет имен, мы используем случайно сгенерированное число, чтобы создать поддельное имя пользователя.

var username = "client-" + Math.floor(Math.random() * 10000);

function setupWebSocket() {
   socket = new ReconnectingWebSocket(...);
   
   ...
   
   socket.onmessage = function(message) {
       var data = JSON.parse(message.data);
       data["messages"].forEach(function(message) {
           if ($("#message-container").children(0).attr("id") == "empty-message") {
               $("#message-container").empty();
           }
           if (message["username"] === username) {
               $("#message-container").append("< div class='message self-message'>< b>(You)< /b> " + message["content"]);
           } else {
               $("#message-container").append("< div class='message'>< b>(" + message["username"] + ")< /b> " + message["content"]);
           }
           $("#message-container").children().last()[0].scrollIntoView();
       });
   };
}

Наконец, напишите метод публикации нового сообщения. Нам просто нужно получить текст в поле ввода, а затем отправить его в сокет с «sendMessage» в качестве «action», сгенерированным нами именем пользователя в качестве «username» и текстом в качестве «content».

// Отправляет сообщение в веб-сокет, используя текст в строке сообщения
function postMessage() {
   var content = $("#post-bar").val();
   if (content !== "") {
       data = {"action": "sendMessage", "username": username, "content": content};
       socket.send(JSON.stringify(data));
       $("#post-bar").val("");
   }
}

Наконец, вернитесь в index.html в каталоге templates/chat. Свяжите новый сценарий custom.js, настройте соединение WebSocket при загрузке страницы и опубликуйте новое сообщение, когда кнопка Post или нажата.

< body>
   
   ...
   
   < script src="{% static 'chat/custom.js' %}">< /script>
   < script>
       $(document).ready(function() {
           setupWebSocket();
       });
       $("#post-btn").on("click", function() {
           postMessage();
       });
       $("#post-bar").on("keyup", function(e) {
           if (e.keyCode == 13) {
               postMessage();
           }
       });
   < /script>
< /body>

Теперь вернитесь в каталог client и перезапустите сервер.

cd ..
python manage.py runserver

Перезагрузите localhost:8000 в окне браузера. Вы должны увидеть до 10 первых сообщений в первоначально загруженных базах данных, и теперь вы сможете отправлять сообщения.

Для еще большего удовольствия попробуйте открыть несколько окон браузера одновременно. Все клиенты должны обновляться при публикации нового сообщения, и они должны иметь возможность общаться друг с другом.

Шаг 8: Защитите приложение

Большая часть функциональности была написана на этом этапе, но нет постоянного чувства идентификации, и злоумышленники могли бы в любом случае притвориться кем-то еще, просто изменив Javascript. Реальное приложение почти наверняка потребует механизма проверки идентификации и не может полагаться на код на стороне клиента (такой как Javascript) для предоставления этой информации бэкэнду, поскольку ему нельзя доверять.

Поэтому, чтобы сделать приложение более реалистичным, мы добавим возможность регистрации и авторизации (используя встроенные django-uniauth) и защитим вызовы внутреннего API с помощью JWT. Аутентификация на основе токенов в настоящее время является наиболее популярным способом защиты API WebSocket, а JWT сама по себе является популярной и простой схемой аутентификации на основе токенов, что делает ее подходящим выбором.

Сначала установите django-uniauth.

pip install django-uniauth

Затем в project/settings.py добавьте «uniauth» в INSTALLED_APPS.

INSTALLED_APPS = [
   ...
   'uniauth',
]

Установите соответствующий бэкэнд AUTHENTICATION_BACKENDS. (Возможно, вам придется создать настройку, если ее еще нет).

AUTHENTICATION_BACKENDS = [
   'uniauth.backends.UsernameOrLinkedEmailBackend',
]

И добавьте следующие настройки:

LOGIN_URL = '/accounts/login/'
UNIAUTH_LOGIN_REDIRECT_URL = '/'
UNIAUTH_LOGIN_DISPLAY_CAS = False
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
UNIAUTH_FROM_EMAIL = 'uniauth@serverless-chat.com'

В первой строке указывается, на какой URL перенаправляется пользователь, когда он пытается получить доступ к представлению, защищенному декоратором login_required, без входа в систему. Во второй строке указывается, на какой URL он перенаправляется при входе вручную. Страница входа, так как у нас нет серверов CAS для входа.

Четвертая строка говорит Django распечатать все исходящие электронные письма на консоль. django-uniauth использует электронную почту для таких вещей, как подтверждение электронной почты, так что это простой способ получить эту функциональность локально, не настраивая реальный SMTP-сервер. Последняя строка устанавливает адрес отправителя в этих письмах на соответствующее имя.

Затем включите URL-адреса пакета в project/urls.py.

urlpatterns = [
   ...
   path('accounts/', include('uniauth.urls', namespace='uniauth')),
   path('', include('chat.urls')),
]

Защитите индексное представление chat/views.py с помощью предоставленного декоратора login_required.

...
from uniauth.decorators import login_required

@login_required
def index(request):
   ...

Наконец, вернитесь в каталог client, примените все новые миграции и перезапустите сервер.

python manage.py migrate
python manage.py runserver

Зайдя на localhost:8000 в браузер, вы должны увидеть страницу входа django-uniauth.

Создайте учетную запись, нажав «Create an Account» и пройдя процедуру регистрации (проверьте окно консоли, на котором запущен сервер, чтобы получить ссылку для проверки электронной почты!), Или с помощью команды Django createsuperuser.

python manage.py createsuperuser

В любом случае, как только учетная запись будет создана, войдите в систему, и вы должны будете вернуться на главную страницу.

Теперь, когда у нас есть правильная аутентификация пользователя, давайте настроим JWT. Мы будем использовать библиотеку PyJWT в этом руководстве. Сначала установите ее.

pip install pyjwt

Затем перейдите в свой каталог backend и добавьте его в свой requirements.txt. Добавленная строка должна выглядеть примерно так:

PyJWT==*.*.*

Вы увидите номер версии в конце вывода команды pip install. В качестве альтернативы вы можете запустить просмотр всех установленных пакетов pip freeze, а затем скопировать строку, начинающуюся с «PyJWT» в requirements.txt.

Затем откройте handler.py и измените обработчик подключения, чтобы прочитать токен, переданный в качестве параметра URL, и попытайтесь декодировать его, используя выбранную вами строку (в данном случае я использую «FAKE_SECRET»). Эта строка будет общим секретным ключом между внутренним API и клиентским приложением, которое JWT использует для кодирования / декодирования токенов. Таким образом, если JWT может успешно декодировать токен (он автоматически проверит подпись как часть метода decode), мы знаем, что он был создан нашим клиентским приложением.

Если токен не был предоставлен или токен не может быть декодирован, соединение должно прерваться немедленно.

import jwt
def connection_manager(event, context):
   ...
   
   token = event.get("queryStringParameters", {}).get("token")
   
   if event["requestContext"]["eventType"] == "CONNECT":
       logger.info("Connect requested")

       # Убедитесь, что токен был предоставлен
       if not token:
           logger.debug("Failed: token query parameter not provided.")
           return _get_response(400, "token query parameter not provided.")

       # Проверка токена
       try:
           payload = jwt.decode(token, "FAKE_SECRET", algorithms="HS256")
           logger.info("Verified JWT for '{}'".format(payload.get("username")))
       except:
           logger.debug("Failed: Token verification failed.")
           return _get_response(400, "Token verification failed.")
       
       ... # Добавить идентификатор соединения в базу данных

Мы также будем использовать JWT в этой функции send_message, чтобы пользователи не могли подделать свои имена пользователей. Отредактируйте, send_message чтобы потребовать token в качестве поля данных вместо username, затем проверьте токен и извлеките его username из пейлоуда, в случае успеха (мы настроим клиента на выполнение этого в ближайшее время). Завершите функцию раньше, если токен не будет проверен.

def send_message(event, context):   ...
   
   # Убедитесь, что все обязательные поля были предоставлены
   body = _get_body(event)
   if not isinstance(body, dict):
       logger.debug("Failed: message body not in dict format.")
       return _get_response(400, "Message body not in dict format.")
   for attribute in ["token", "content"]:
       if attribute not in body:
           logger.debug("Failed: '{}' not in message dict."\
                   .format(attribute))
           return _get_response(400, "'{}' not in message dict"\
                   .format(attribute))
   
   # Проверка токена
   try:
       payload = jwt.decode(body["token"], "FAKE_SECRET", algorithms="HS256")
       username = payload.get("username")
       logger.info("Verified JWT for '{}'".format(username))
   except:
       logger.debug("Failed: Token verification failed.")
       return _get_response(400, "Token verification failed.")
   
   ... # Получить следующий индекс сообщения
   
   # Добавить новое сообщение в базу данных
   timestamp = int(time.time())
   content = body["content"]
   table.put_item(Item={"Room": "general", "Index": index,
           "Timestamp": timestamp, "Username": username,
           "Content": content})
   
   ... # Получить все текущие соединения + отправить данные сообщения всем соединениям

Затем повторно разверните бэкэнд-API.

serverless deploy

Вернитесь в каталог client, перейдите в каталог приложения чата и откройте views.py. Измените индексное представление, чтобы закодировать токен с именем пользователя, содержащимся в полезной нагрузке, и выбранной секретной строкой в ​​качестве секрета, и передать токен как часть контекста представления.

from django.shortcuts import render
from uniauth.decorators import login_required
import jwt

@login_required
def index(request):
   token = jwt.encode({"username": request.user.username}, "FAKE_SECRET",
           algorithm="HS256").decode("utf-8")
   return render(request, "chat/index.html", {"token": token})

Затем откройте static/chat/custom.js и удалите случайно сгенерированное имя пользователя. Измените, setupWebSocket чтобы принимать аргументы username и token, и передавать маркер в качестве параметра URL при подключении к WebSocket. Измените, postMessage чтобы принять параметр token, и включить его как часть словаря сообщений вместо username.

var socket;

// Подключаемся к WebSocket и настраиваем слушателей
function setupWebSocket(username, token) {
   socket = new ReconnectingWebSocket("wss://0hhv85vkol.execute-api.us-east-1.amazonaws.com/dev?token=" + token);   ...
   
}

// Отправляет сообщение в веб-сокет, используя текст в строке сообщения
function postMessage(token) {
   var content = ...
   if (content !== "") {
       data = {"action": "sendMessage", "token": token, "content": content};       ...
       
   }
}

Затем откройте templates/chat/index.html и измените обработчики событий, чтобы передать соответствующие аргументы.

< body>
   
   ...
   
   < script>
       $(document).ready(function() {
           setupWebSocket("{{ request.user.username }}", "{{ token }}");
       });
       $("#post-btn").on("click", function() {
           postMessage("{{ token }}");
       });
       $("#post-bar").on("keyup", function(e) {
           if (e.keyCode == 13) {
               postMessage("{{ token }}");
           }
       });
   < /script>
< /body>

Теперь перезапустите клиентский сервер и проверьте конечный продукт!

python manage.py runserver

Оно должно быть функционально эквивалентно тому, когда приложение запускалось в последний раз, но теперь имя пользователя отправленных сообщений будет соответствовать имени пользователя, вошедшего в систему.

И это все!

Теперь вы с нуля создали безсерверное приложение с WebSocket и, надеюсь, научились многому.

Вы можете найти код, используемый в этом руководстве, в этом репозитории Github. И не стесняйтесь оставлять любые отзывы в комментариях!

Перевод статьи: Creating a Chat App with Serverless, WebSockets, and Python: A Tutorial

#Python #Django
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

Напиши статью и выиграй годовую подписку на Яндекс плюс или лицензию от Jet Brains

Участвовать