Создание приложения чата с помощью 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>< /body> < /html>Welcome to the
Serverless Chatroom
Catch up on the conversation below!
Из каталога приложения чата создайте каталог 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