Сервис аватар в web-приложении на Python и Starlette

programming

Опубликован:
2025-01-14T10:37:51.275803Z
Отредактирован:
2025-01-14T10:37:51.275803Z
Статус:
публичный
18
0
0

Сервис пользовательских аватар в web-приложении на Python и Starlette, процесс разработки которого мы детально изучаем в этом цикле статей, выполняет две основные функции: отображение и установка пользовательского аватара. В этом обзоре я покажу решение первой функциональной задачи и научу разрабатываемую программу отображать пользовательские аватары по типовому url-адресу в соответствии с замыслом и техническим заданием на проект. Решение второй функциональной задачи этого сервиса я покажу в последующих выпусках этого блога, на этапе разработки страницы пользовательского профиля.

Пользовательский аватар

Пользовательский аватар — это персонифицированная картинка заданных размеров, которую выбирает авторизованный пользователь сервиса самостоятельно, и которая отличает данного конкретного пользователя от всех остальных пользователей сайта. Эта картинка прикрепляется к профилю и всем авторским материалам, которые данный конкретный пользователь публикует на сайте. Все статьи в блоге, комментарии, приватные сообщения пользователя в заголовках будут содержать аватар автора. Кроме этого, аватар и username текущего авторизованного пользователя я планирую отображать в главном меню web-приложения. Именно по этой причине разработка сервиса пользовательских аватар инициирована именно сейчас, перед разработкой страницы входа в сервис.

Все читали совершенно великолепный манускрипт Мигеля о разработке web-сервисов с помощью Flask, все знают, что Мигель в этом по праву ставшем культовым учебнике предлагает для отображения пользовательских аватар использовать сторонний сервис — gravatar.com. Предыдущая версия web-сайта, на котором вы прямо сейчас читаете этот блог, была разработана по упомянутому учебнику, и я должен признать, что gravatar.com по моим ощущениям совсем не является сервисом мечты. Именно поэтому в текущей версии сайта я использую собственный вариант сервиса пользовательских аватар.

Раскрываем замысел

В соответствии с замыслом, на котором базируется техническое задание на разработку текущего web-приложения, аватар пользователя может менять свои размеры, в зависимости от того, на какой конкретно странице и в каком конкретно заголовке он отображается. Например, в профиле пользователя аватар должен иметь размер 160x160 пикселей, в заголовке авторской статьи 90x90, в заголовке авторского комментария 60x60 и так далее. Кроме этого, необходимо предусмотреть бланк аватар — картинку, которая будет отображаться в качестве аватара у всех пользователей, которые по каким-то причинам не установили аватар для своего профиля.

Техническое задание на проект предполагает, что пользовательские аватары сервис будет обслуживать по типовому url-адресу следующей формы:

/ava/username/size.

В этом типовом адресе поля username и size являются переменными параметрами и могут принимать различные значения. По полю username определяется принадлежность аватара конкретному пользователю, а по полю size определяется размер отображаемой картинки в пикселях — квадрат sizeXsize, где size является целым числом в интервале от 22 до 160.

Сервер сети на запросы клиентов по соответствующим форме предлагаемого типового адреса url-адресам должен возвращать ответ с указанным в заголовке ответа соответствующим типом контента (Content-Type) и бинарным кодом картинки соответствующего размера. Если пользователь с указанным в url-адресе именем не существует в списке зарегистрированных пользователей, или размер запрашиваемой картинки выходит за определённые техническим заданием пределы, сервер должен вернуть на запрос по такому адресу статус код 404 и типовую страницу для этого статус кода. Таков план...

Определяем место хранения пользовательских аватар

Web-сервер, как и компьютер любого другого назначения, умеет хранить данные двумя способами:

  • в файлах;

  • в базах данных.

В рамках разрабатываемого web-приложения я использую для хранения всех данных базу данных PostgreSQL, которая умеет хранить данные в любой форме — числа, текст, бинарный код. Аватары зарегистрированных в сервисе пользователей я планирую хранить в одной из таблиц этой базы данных. Эту таблицу мне нужно воспроизвести SQL-кодом и создать в подключенной к приложению на стадии разработки базе данных websitedev, она уже существует и кое-что хранит в своих таблицах, и её мне нужно дополнить ещё одной таблицей.

Запускаю терминал, вхожу в корневой каталог приложения и, находясь в нём, создаю с помощью текстового редактора Vim файл с именем avatar.sql.

$ vim sql/avatar.sql

В этот файл пишу следующий код.

CREATE TABLE avatars (
    picture bytea NOT NULL,
    user_id integer REFERENCES users(id) UNIQUE
);

Этот код создаёт таблицу с именем avatars, в которой определяет два столбца:

  • picture хранит бинарный код изображения;

  • user_id хранит уникальный идентификатор пользователя из таблицы users, ограничение UNIQUE определяет, что каждый пользователь из таблицы users может иметь один единственный аватар.

Сохраняю изменения в файл и покидаю текстовый редактор.

Имея такую таблицу в отдельном SQL-файле, я могу без труда создать её в базе данных с именем websitedev следующей простой командой.

$ psql -d websitedev -f sql/avatar.sql

Эта команда никак не затронет уже имеющиеся в базе данных, созданные ранее таблицы. Теперь я могу содержимое файла avatar.sql транслировать в файл auth.sql.

$ cat sql/avatar.sql >> sql/auth.sql

Файл с именем avatar.sql больше не потребуется, его можно удалить.

$ rm -rf sql/avatar.sql

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

Бланк аватар

Бланк, от английского blank — пустой, чистый, незаполненный. Если пользователь по какой-то причине не установил в своём профиле аватар, на месте аватара во всех авторских материалах пользователя будет отображаться типовая картинка. Вот как она выглядит.

2QJ7LGb12W.jpg

Эту картинку в формате PNG я буду хранить в статических файлах приложения, копирую её файл в каталог images, вот как этот файл выглядит в дереве каталогов website:

VHalw5R3L6.png

Масштабируем пользовательский аватар

По условиям технического задания пользовательский аватар должен иметь указанный в параметре size url-адреса размер. Но в базе данных определён бинарный код одной единственной картинки, которая имеет какой-то конкретный размер. Кроме этого, картинка бланк аватара тоже имеет какой-то конкретный размер. Это обстоятельство требует от разработчика программирования специального инструмента, с помощью которого можно в каждом ответе сервера масштабировать отправляемую в ответе картинку до заданных параметром size размеров. Решить эту задачу мне поможет ещё одна сторонняя библиотека — Pillow. Запускаю ещё один экземпляр терминала, в нём вхожу в корневой каталог приложения и активирую виртуальное окружение. В этом терминале исполняю следующую команду.

$ pip install pillow

QRbulr0n04.png

Теперь мне необходимо разработать программу, которая будет масштабировать заданную бинарным кодом картинку до требуемых размеров, используя инструменты Pillow. Для этого создаю отдельный модуль с именем tools.py в каталоге главной подпрограммы (main).

$ vim webapp/main/tools.py

В окне текстового редактора пишу следующий код...

Поскольку разрабатываемая программа будет оперировать либо переданным ей бинарным кодом, либо файлом с переданным ей именем, в этой программе мне потребуются два модуля стандартной библиотеки: io и os. Подключаю.

import io
import os

Из только что установленной библиотеки Pillow мне необходим класс Image, подключаю.

from PIL import Image

Адрес файла бланк аватара в файловой системе сервера мне необходимо выразить в терминах Питона, для этого мне потребуется объект images из модуля dirs.py базового каталога приложения, подключаю.

from ..dirs import images

Код разрабатываемой программы я заключу в пространство имён функции с именем resize — имя функции соответствует выполняемому ей действию. В качестве первого аргумента эта функция будет принимать переменную size, выражающую требуемый размер картинки целым числом. Второй аргумент функции (image) является байт-строкой и передаёт функции бинарный код исходного изображения, этот аргумент может принимать значение None. Вот как выглядит код функции resize:

def resize(size, image=None):
    b = None
    if image is None:
        f = os.path.join(images, 'empty.png')
        try:
            image = Image.open(f)
        except OSError:
            return None
    else:
        b = io.BytesIO(image)
        try:
            image = Image.open(b)
        except:
            b.close()
            return None
    v = io.BytesIO()
    out = image.resize((size, size))
    out.save(v, format='PNG')
    v.seek(0)
    res = v.read()
    out.close()
    v.close()
    image.close()
    if b:
        b.close()
    return res

Здесь я создаю объект image, который является экземпляром библиотечного класса Image. В этом объекте будет храниться либо переданный функции вторым аргументом бинарный код, либо бинарный код файла бланк аватара. Далее, при помощи инструментов Pillow я масштабирую хранящееся в объекте image изображение до размера size по высоте и ширине, сохраняю масштабированное изображение в промежуточный объект с именем v, читаю его бинарный код в переменную res и возвращаю эту переменную. Таким образом, в результате заложенных в теле функции логических действий функция вернет либо бинарный код масштабированного изображения, либо None. Вот как этот код выглядит в окне моего текстового редактора, внимание на снимок экрана.

AVnevVH02F.png

Определяем параметры кэширования на клиенте

Аватары пользователей являются бинарными файлами изображений в любой своей инкарнации в рамках разрабатываемого web-приложения. Все передаваемые сервером бинарные файлы на клиенте выгодно кэшировать, дабы избежать чрезмерного трафика в каждом ответе сервера. Для этого в заголовке ответа сервера, передающего на клиент бинарные данные, необходимо указать время жизни такого кэша. Время жизни кэша статических файлов будет задано настройками web-сервера при развёртывании приложения. А время жизни кэша генерируемых сервером бинарных файлов необходимо задать в каждом таком ответе сервера. Это значит, что мне понадобится ещё одно поле в файле настроек приложения. Открываю этот файл в текстовом редакторе.

$ vim .env

И дописываю в конец файла ещё одну строчку.

SEND_FILE_MAX_AGE=3600

В данном случае я задал время жизни кэша равное одному часу, для отладочного сервера это значение вполне подойдёт. Только что созданный параметр SEND_FILE_MAX_AGE я и буду использовать при определении соответствующего заголовка соответствующего ответа сервера, об этом поговорим чуть позже. Вот как файл настроек выглядит в окне моего текстового редактора после правки.

SWpqsV0Ddk.png

Url-адрес пользовательского аватара

Пришло время определить в экземпляре ASGI-приложения, в его параметре routes целевой адрес пользовательского аватара. Открываю в текстовом редакторе Vim файл конфигурации приложения.

$ vim webapp/__init__.py

В коде этого файла дописываю в соответствующую процедуру импорта ещё одну функцию представления — show_avatar.

from .main.views import show_avatar, show_favicon, show_index, show_robots

Эта функция представления на данный момент не существует, её я создам чуть позже, и именно она будет указана в качестве обработчика на целевом url-адресе пользовательского аватара. Нахожу в файле процедуру инициализации переменной с именем app и дописываю в список параметра routes ещё один экземпляр библиотечного класса Route.

            ...
            Route('/ava/{username}/{size:int}', show_avatar, name='ava'),
            ...

Здесь username и size в выражающей адрес строке являются параметрами url-адреса, к ним можно получить доступ в рамках логики обработчика этого url посредством свойства path_params представляющего HTTP-запрос объекта.

dX2HwK9qsf.png

Сохраняю изменения в файл. В командном режиме редактора Vim открываю файл views.py из каталога главной подпрограммы (main).

:tabnew webapp/main/views.py

Именно в этом файле будет храниться функция представления show_avatar.

Для реализации задуманного в рамках асинхронной функции представления мне будет необходим вызов показанной выше функции resize из модуля tools.py, и поскольку эта функция имеет блокирующий ввод вывод, её вызов я намерен оформить в соседней ветке рядом с основным event loop. Этот финт в свою очередь потребует соответствующих инструментов из модулей стандартной библиотеки Питона — asyncio и functools. Подключаю.

import asyncio
import functools

Если в параметрах url-адреса обработчику будут переданы недопустимые значения, такое возможно в публичном пространстве Интернет, обработчик должен будет сгенерировать соответствующее исключение — это можно сделать с помощью библиотечного класса HTTPException из соответствующего модуля Starlette. Подключаю.

from starlette.exceptions import HTTPException

В рамках логики show_avatar посредством вызова resize я намерен получить двоичный код соответствующего запросу изображения и на его основе сформировать ответ сервера, мне необходим ещё один библиотечный класс — Response из соответствующего модуля Starlette. Подключаю.

from starlette.responses import FileResponse, PlainTextResponse, Response

Кроме этого я подключу в этот модуль ещё и константу E404, которая была создана ранее, а так же разработанную на этом этапе разработки функцию resize.

from ..errors import E404
from .tools import resize

Код функции представления show_avatar будет иметь следующий соответствующий замыслу вид:

async def show_avatar(request):
    ## получаю переданный в url-адресе запроса
    ## параметр size и сохраняю его в одноимённой переменной.
    size = request.path_params.get('size')
    ## проверяю значение size
    if size < 22 or size > 160:
        ## если условие выполняется генерирую ошибку
        ## со статус кодом 404
        raise HTTPException(status_code=404, detail=E404)
    ## создаю соединение с базой данных приложения
    conn = await get_conn(request.app.config)
    ## проверяю наличие в таблице users учётной
    ## записи пользователя с переданным в параметре
    ## url-адреса именем 
    res = await conn.fetchrow(
        'SELECT id, username FROM users WHERE username = $1',
        request.path_params.get('username'))
    ## если учётная запись с таким username не существует
    if res is None:
        ## закрываю соединение с базой данных и
        ## генерирую исключение со статус кодом 404
        await conn.close()
        raise HTTPException(status_code=404, detail=E404)
    ## проверяю наличие в таблице avatars записи
    ## с соответствующим полученному из таблицы users
    ## идентификатору (id)
    ## полученное значение поля picture сохраняю в переменной
    ## ava, это будет либо None, либо байт-строка с бинарным кодом
    ava = await conn.fetchval(
        'SELECT picture FROM avatars WHERE user_id = $1', res.get('id'))
    ## закрываю соединение с базой данных
    await conn.close()
    ## определяю текущий event loop процесса исполнения
    loop = asyncio.get_running_loop()
    ## в соседней ветке исполняю вызов блокирующей
    ## функции resize, полученное в результате этого вызова
    ## значение сохраняю в переменной image
    image = await loop.run_in_executor(
        None, functools.partial(resize, size, ava))
    ## создаю HTTP-ответ сервера, передав классу
    ## Response хранящийся в переменной image код
    response = Response(image, media_type='image/png')
    ## проверяю значение переменной ava
    if ava is None:
        ## если условие выполняется, добавляю ответу
        ## сервера заголовок cache-control с временем жизни
        ## клиентского кэша равным нулю
        response.headers.append('cache-control', 'public, max-age=0')
    else:
        ## в ином случае заголовок cache-control ответа сервера
        ## получает время жизни указанное в соответствующем
        ## поле файла настроек приложения
        reaponse.headers.append(
            'cache-control',
            'public, max-age={0}'.format(
                request.app.config.get(
                    'SEND_FILE_MAX_AGE', cast=int, default=0)))
    ## возвращаю полученный HTTP-ответ
    return response

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

async def show_favicon(request):
    if request.method == 'GET':
        response = FileResponse(
            os.path.join(images, 'favicon.ico'))
        response.headers.append(
            'cache-control',
            'public, max-age={0}'.format(
                request.app.config.get(
                    'SEND_FILE_MAX_AGE', cast=int, default=0)))
        return response

Здесь я добавил ответу сервера на адресе /favicon.ico заголовок cache-control с определённым в указанном поле файла настроек временем жизни, абсолютно аналогично предыдущей функции представления.

Сохраняю изменения всех файлов. Всё, замысел реализован, настало время протестировать все внесённые в код изменения и их функциональность.

Тестируем в браузере

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

O5Zc6CYvFR.png

Запускаю браузер и пробую отправить запрос по адресу иконки для избранного.

bHBX5k8eY3.png

Как видно на снимке экрана выше, в заголовках ответа сервера на этом url-адресе имеется заголовок Cache-Control, и в этом заголовке указано время жизни клиентского кэша, соответствующее значению поля SEND_FILE_MAX_AGE файла настроек приложения.

В базе данных приложения на текущий момент хранится две учётные записи пользователей, и username одного из пользователей — avm4. Давайте попробуем получить в браузере текущий аватар этого пользователя размером 48x48 пикселей.

Klpcz1zvmD.png

На снимке экрана выше видно, что размер полученной картинки соответствует заданному в url-адресе значению поля size. При этом в ответе сервера есть заголовок Cache-Control, и в нём указано время жизни кэша — 0, что соответствует ожиданиям для бланк аватара. Попробую изменить размер картинки с помощью url-адреса, меня интересует затраченное сервером время на ответ.

c7CTgiMGeM.png

Как видно на снимке экрана выше, время ответа сервера вполне приемлемо, если учитывать, что тест производится на достаточно хилом процессоре AMD e1.

Что будет, если задать размер аватара меньше допустимого по условиям? Внимание на следующий снимок экрана.

63SV83sfst.png

Сервер отдал статус код 404 и страницу с ошибкой доступа. А если запросить аватар несуществующего в базе данных пользователя? Вбиваю в url-адрес username stranger — пользователя с таким именем в базе данных не существует.

T9cYGco0ZO.png

Сервер опять ответил статус кодом 404 и страницей с ошибкой доступа в полном соответствии с ожиданиями.

Увы, на текущий момент пока не получится протестировать актуальный аватар, который пользователь установит в свой профиль, страниц пользовательских профилей в сервисе пока не существует, это значит, что к программированию сервиса пользовательских аватар придётся вернуться ещё раз на одном из последующих этапов разработки. А на текущий момент все цели этой демонстрации полностью достигнуты.

Подводим промежуточный итог

В разрабатываемом web-приложении появилась возможность отображения пользовательских аватар. Текущую версию кода приложения можно увидеть в моём профиле на github.com по этой короткой ссылке. В одном из следующих выпусков этого цикла статей я покажу вёрстку страницы входа в сервис и разработку сопутствующего кода на backend-е. Продолжение следует...

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