Это восьмая статья в серии, где я описываю свой опыт написания веб-приложения на Python с использованием микрофреймворка Flask.
Цель данного руководства — разработать довольно функциональное приложение-микроблог, которое я за полным отсутствием оригинальности решил назвать microblog.
Наш маленький микроблог потихоньку растёт, и сегодня мы затронем темы, которые необходимы для законченного приложения.
Сегодня мы немного поработаем с нашей базой данных. Каждый пользователь нашего приложения должен иметь возможность выбирать пользователей, которых он хочет отслеживать, также наша база должна хранить данные о том кто кого отслеживает. Все социальные приложения имеют эти возможности в разных вариациях. Одни называют это Контактами, другие Связями, Друзьями, Приятелями или Подписчиками. Некоторые сайты используют похожую идею для списка Разрешенных и Игнорируемых пользователей. Мы назовем их Подписчиками, но реализовывать будем не держась за название.
Перед тем как мы начнем писать код, давайте подумаем о той функциональности, которую мы хотим получить от этой фичи. Давайте начнем с самого очевидного. Мы хотим чтобы нашим пользователям было удобно поддерживать списки подписок на других пользователей. С другой стороны, мы хотим знать список подписчиков каждого пользователя. Мы также хотим иметь возможность узнать есть ли у пользователя подписчики или подписан ли он сам на других пользователей. Пользователи будут кликать на ссылку «Подписаться» в профиле любого другого пользователя, чтобы начать его отслеживать. Таким же образом, клик на ссылку «отписаться», будет отменять подписку на пользователя. Последнее требование является возможность пользователя запросить из базы все посты отслеживаемых пользователей.
Итак, если вы думали что это будет быстро и легко, подумайте еще раз!
Мы сказали что хотим иметь для всех пользователей списки подписчиков и подписок. К сожалению, реляционные базы данных не имеют типа @@list@@, всё что у нас есть это таблицы с записями и отношения между записями. У нас уже есть таблица в нашей базе, предствляющая пользователей, осталось придумать отношения зависимости которые смоделируют связи подписчиков/подписок. Это хороший момент, чтобы разобрать три типа отношений в реляционных базах:
Мы уже видели отношение «один-ко-многим» в предыдущей статье о базе данных:
![image](https://habrastorage.org/r/w1560/getpro/habr/post_images/aa3/cff/63d/aa3cff63d109e4418fab6fda82f958ed.png)
Две сущности связаные подобными отношениями это
Понятно, что поле
Отношения «многие-ко-многим» немного сложней. Для примера рассмотрим БД в котором хранятся
Для отношений такого типа мы должны быть в состоянии запросить БД и получить список учителей, которые учат студента, и список студентов в классе учителя. Оказывается это довольно сложней представить в базе, такое отношение не может быть смоделировано добавлением внешних ключей к уже существующим таблицам.
Реализация отношения «многие-ко-многим» требует использования вспомогательной таблицы, называемую сводной таблицей. Вот, например, как будет выглядеть БД для студентов и преподавателей:
![image](https://habrastorage.org/r/w1560/getpro/habr/post_images/585/415/716/585415716aa54bef9df8dc95fbda52c3.png)
Хоть это может показаться непростым, но сводная таблица может ответить на многие наши вопросы, такие как:
Отношения «один-к-одному» это частный случай отношений «один-ко-многим». Представление очень похоже, но содержит запрет на добавление больше чем одной связи, чтобы не превратиться в «один-ко-многим». Хотя есть случаи, в которых этот тип отношений полезен, это случается не так часто, как в случае двух других типов, так как в ситуации связи двух таблиц отношением «один-к-одному» может иметь смысл объединить таблицы в одну.
Из приведенных выше отношений мы легко можем определить, что нам подходит модель данных «многие-ко-многим», потому что пользователь может следить за множеством других пользователей и пользователь может иметь много подписчиков. Но есть особенность. Мы хотим представлять пользователей подписанных на других пользователей, но мы имеем только одну таблицу пользователей. Итак, что мы должны использовать как вторую сущность в отношениях «многие-ко-многим»?
Конечно, второй сущностью в отношениях будет та же самая таблица пользователей. Отношения в которых экземпляры сущности связаны с дргими экземплярами той же сущности называются самоссылающимися отношениями, и это именно то, что нам нужно.
Это диаграмма наших «многие-ко-многим» отношений:
![image](https://habrastorage.org/r/w1560/getpro/habr/post_images/649/c7a/a48/649c7aa489c545ac79863698b3d348a1.png)
Таблица
Изменения в нашей модели будут не очень большие. Мы начнем с добавления таблицы @@followers@@ (файл @@app/models.py@@):
Это прямая трансляция связей таблиц из нашей диаграммы. Обратите внимание, что мы не объявляли эту таблицу в качестве модели, как мы делали для
Далее мы описываем отношения многие-ко-многим в таблице
Конфигурация отношений нетривиальна и требует некоторых пояснений. Мы используем функцию
Не отчаивайтесь, если это тяжело для понимания. Мы увидим, как использовать эти запросы, и тогда всё станет понятней. Так как мы сделали обновления базы данных, теперь мы можем создать новую миграцию:
Этим мы завершили изменения базы данных. Осталось совсем немного покодить.
Для поддержки повторного использования кода, мы реализуем функционал подписки\подписчики внутри модели
Ниже приведем код для добавления и удаления подписчиков, определенный в виде методов модели
Благодаря силе SQL Alchemy, которая делает много работы, эти методы являются удивительно простыми. Мы просто добавляем или удаляем элементы, а SQLAlchemy делает всю остальную работу. Методы
Метод
Давайте напишем тест для нашего кода (файл
После добавления этого теста в тест-фреймворк мы можем запустить набор тестов командой:
И если все работает все тесты будут успешно пройдены.
Наша текущая модель БД поддерживает большинство требований, которые мы перечислили в начале. То, чего нам не хватает, на самом деле, реализовать трудней всего. На главной странице сайта будут показаны сообщения, написаные всеми людьми, за которыми следит наш залогиненый пользователь, поэтому нам нужен запрос, который возвращает все эти сообщения.
Наиболее очевидным решением является запрос, который даст список отслеживаемых людей, который мы уже можем сделать. Тогда для каждго из этих пользователей мы выполним запрос, чтобы получить его сообщения. Когда у нас будут все сообщения, мы можем объединить их в единый список и отсортировать по времени. Звучит хорошо? Не совсем.
Этот подход имеет пару проблем. Что произойдет, если пользователь отслеживает тысячу человек? Нам придется выполнить тысячу запросов к базе только чтобы собрать сообщения. И теперь у нас есть тысячи списков в памяти, которые мы должны отсортировать и объединить. На нашей главной странице реализована нумерация страниц, поэтому мы не будем показывать все доступные сообщения, а только первые 50, и ссылки по которым можно посмотреть следующие 50. Если мы собираемся показывать сообщения упорядоченые по дате, как мы узнаем какие из них являются последними 50 сообщениями всех пользователей, если сначала не получим все сообщения и не отсортируем их.
На самом деле это ужасное решение, которое очень плохо масштабируется. Хоть этот способ по сбору и сортировке хоть как-то работает, он недостаточно эффективен. Это именно та работа в которой реляционные БД преуспевают. База данных содержит индексы, которые позволяют ей выполнять запросы и сортировки гораздо эффективней, чем мы можем сделать это со своей стороны.
Мы должны придумать запрос, который выражает то, что мы хотим получить, а база данных вычислит каким образом более эффективно извлечь необходимую нам информацию.
Чтобы развеять тайну, вот запрос, который сделает то, что нам нужно. К сожалению это еще один перегруженый однострочник, который мы добавим к модели пользователя (файл
Попробуем расшифровать этот запрос шаг за шагом. Здесь 3 части: join, filter, и order_by.
Чтобы понять что делает операция join, давайте рассмотрим пример. Предположим, у нас есть таблица
Другие поля таблицы не отображены, чтобы не усложнять пример.
Давайте предположим что наша сводная таблица говорит что пользователь «john» подписан на «susan» и «david», пользователь «susan» подписана на «mary» и «mary» подписана на «david». Тогда сводная таблица будет выглядеть примерно так:
И в заключение, наша таблица
Здесь тоже убраны некоторые поля, чтобы не усложнят пример.
Ниже приводится часть нашего запроса с join изолированная от остальной части:
Операция
В этом примере мы хотим чтобы поля
Для выполнения данного слияния мы берем каждую запись из таблицы
Результат выполнения join на нашем примере в этой временной таблице:
Заметьте как сообщение с user_id=1 было удалено из join, потому что нет записей в таблице подписчиков что имеется followed_id=1. Также обратите внимание, что сообщение с user_id=4 появляется дважды, потому что таблица подписчиков имеет два вхождения с followed_id=4.
Операция join дала нам список сообщений людей, за которыми кто-то следит, не уточнив, кто является подписчиком. Нас интересует подмножество этого списка, в котором только те сообщения, которые отслеживает один конкретный пользователь. Так что мы будем фильтровать эту таблицу по подписчику. Часть запроса с фильтром будет такая:
Помните что запрос выполняется в контексте нашего целевого пользователя, поэтому метод self.id класса User в этом контексте выдает id интересующего нас пользователя. С помощью этого фильтра мы говорим базе данных, что хотим оставить только те записи из таблицы, созданной с помощью join в которых наш пользователь указан как подписчик. Продолжая наш пример, если запросим пользователся с id=1, тогда мы придем к другой временной таблице:
И это как раз те посты, которые нам нужны!
Помните что запрос был выполнен на классе Post, так что даже если мы закончим во временной таблице не относящейся ни к одной модели, результат будет включен в эту временную таблицу, без дополнительных столбцов добавленых операцией join.
Последним шагом процесса является сортировка результатов по нашим критериям. Часть запроса, которая это делает выглядит так:
Здесь мы говорим, что результаты должны быть отсортированы по полю
Существует только одна незначительная деталь, которая может улучшить наш запрос. Когда пользователи читают посты на которые подписаны, они возможно захотят увидеть свои собственные посты в ленте, и было бы хорошо включить их в результат запроса.
Есть простой способ это сделать, который не требует никаких изменений! Мы просто просто убедимся что каждый пользователь добавлен в базу в качестве своего же подписчика и эта маленькая проблема больше не будет нас заботить. В заключение нашего долгого обсуждения запросов, давайте напишем юнит-тест для нашего запроса (файл tests.py):
Этот тест имеет много кода предварительной настройка, но код самого тестирования довольно короткий. Сначала мы проверяем, что число отслеживаемых постов возвращаемых для каждого пользователя равно ожидаемому. Затем для каждого пользователя мы проверяем что были возвращены правильные посты и они пришли в правильном порядке (обратите внимание, что мы вставляли сообщения с временными метками гарантирующими всегда один и тот же порядок).
Обратите внимание на использование метода followed_post(). Этот метод возвращает объект query, а не результат. Так же работает lazy=«dynamic» в отношениях ДБ.
Это всегда хорошая идея вернуть объект вместо результата, потому что дает вызывающему возможность дополнять запрос перед выполнением.
Есть несколько методов в объекте query которые вызывают выполнение запроса. Мы видели, что count() выполняет запрос и возвращает количество результатов, отбросив сами данные. Мы также использовали first() чтобы вернуть первый результат в списке, а остальные отбросить. В тесте мы использовали метод all() чтобы получить массив со всеми результатами.
Мы сейчас реализовали все необходимые функции подписок, но есть несколько способов улучшить наш дизайн и сделать его более гибким. Все социальные сети, которые мы так любим ненавидеть поддерживают подобные пути связи пользователей, но они имеют больше возможностей для управления информацией. Например, нет возможности блокировать подписчиков. Это добавить еще один слой сложности к нашим запросам, так как мы теперь должны не только выбрать пользователей, но еще и отсеять посты тех пользователей, которые заблокировали нас. Как бы это реализовать?
Простой способ это еще одна самоссылающаяся таблица с отношением многие-ко-многим для записи кто кого блокирует, а еще один join+filter в запросе, который возвращает отслеживаемые посты. Другой популярной возможностью соцсетей является возможность группировать подписчиков в списки, чтобы потом делиться с каждой группой своей информацией. Это также требует дополнительных связей и добавляет сложности к запросам.
У нас не будет этих функций в микроблоге, но если это вызовет достаточный интерес, я буду счастлив написать статью на эту тему. Дайте мне знать в комментариях!
Мы сегодня достаточно продвинулись. Но хотя мы и решили проблемы с настройкой базы данных и запросами, мы не включили новый функционал в наше приложение. К счастью для нас, с этим нет никаких проблем. Нам просто нужно исправить функции представления и шаблоны для вызова новых методов в модели User, когда это необходимо. Так давайте сделаем это.
Делаем себя своим же подписчиком.
Мы решили отметить всех пользователей подписаными на самих себя, чтобы они могли видеть в ленте свои посты.
Мы собираемся сделать это в точке, где пользователям присваиваются первые настройки аккаунта, в обработчике after_login для OpenID( файл 'app/views.py'):
Далее мы определим функции представления подписки и отписки (файл app/views.py):
Это должно быть понятно, но стоит обратить внимание на проверки в которых мы стараемся предотвратить ошибку и пробуем предоставляем сообщение пользователю, когда проблема всё же произошла. Теперь у нас есть функции представления, поэтому мы можем подключить их. Ссылки чтобы подписаться или отписаться будут доступны на странице профиля каждого пользователя (файл app/templates/user.html):
В строке в которой была ссылка «Edit» мы сейчас покажем количество подписчиков, которые есть у пользователя и одну из трех возможных ссылок:
Сейчас вы можете запустить приложение, создать несколько пользователей, войдя с различных аккаунтов OpenID и поиграть с новыми возможностями.
Все, что осталось это показать посты отслеживаемых пользователей на главной странице, но у нас всё еще отсутствует важная часть головоломки, поэтому придется подождать до следующей главы.
Мы реализовали большой кусок нашего приложения сегодня.
Тема связей в БД и запросов является довольно сложной, так что если есть какие-либо вопросы, Вы можете отправить ваши вопросы в комментариях ниже.
В следующей статье мы рассмотрим удивительный мир нумерации страниц, и мы будем, наконец, получать посты из базы данных.
Для ленивых копипастеров ниже ссылка на обновленные исходники нашего микроблога:
Скачать microblog-0.8.zip.
Как всегда, архив не содержит базы данных или виртуального окружения flask. Предыдущие статьи объясняют как развернуть их.
Еще раз спасибо то что читаете мой туториал. Увидимся в следующий раз!
Miguel
Цель данного руководства — разработать довольно функциональное приложение-микроблог, которое я за полным отсутствием оригинальности решил назвать microblog.
Оглавление
Часть 1: Привет, Мир!
Часть 2: Шаблоны
Часть 3: Формы
Часть 4: База данных
Часть 5: Вход пользователей
Часть 6: Страница профиля и аватары
Часть 7: Unit-тестирование
Часть 8: Подписчики, контакты и друзья(данная статья)
Часть 9: Пагинация
Часть 10: Полнотекстовый поиск
Часть 11: Поддержка e-mail
Часть 12: Реконструкция
Часть 13: Дата и время
Часть 14: I18n and L10n
Часть 15: Ajax
Часть 16: Отладка, тестирование и профилирование
Часть 17: Развертывание на Linux (и даже на Raspberry Pi!)
Часть 18: Развертывание на Heroku Cloud
Часть 2: Шаблоны
Часть 3: Формы
Часть 4: База данных
Часть 5: Вход пользователей
Часть 6: Страница профиля и аватары
Часть 7: Unit-тестирование
Часть 8: Подписчики, контакты и друзья(данная статья)
Часть 9: Пагинация
Часть 10: Полнотекстовый поиск
Часть 11: Поддержка e-mail
Часть 12: Реконструкция
Часть 13: Дата и время
Часть 14: I18n and L10n
Часть 15: Ajax
Часть 16: Отладка, тестирование и профилирование
Часть 17: Развертывание на Linux (и даже на Raspberry Pi!)
Часть 18: Развертывание на Heroku Cloud
Краткое повторение
Наш маленький микроблог потихоньку растёт, и сегодня мы затронем темы, которые необходимы для законченного приложения.
Сегодня мы немного поработаем с нашей базой данных. Каждый пользователь нашего приложения должен иметь возможность выбирать пользователей, которых он хочет отслеживать, также наша база должна хранить данные о том кто кого отслеживает. Все социальные приложения имеют эти возможности в разных вариациях. Одни называют это Контактами, другие Связями, Друзьями, Приятелями или Подписчиками. Некоторые сайты используют похожую идею для списка Разрешенных и Игнорируемых пользователей. Мы назовем их Подписчиками, но реализовывать будем не держась за название.
Дизайн функции 'Подписчики'
Перед тем как мы начнем писать код, давайте подумаем о той функциональности, которую мы хотим получить от этой фичи. Давайте начнем с самого очевидного. Мы хотим чтобы нашим пользователям было удобно поддерживать списки подписок на других пользователей. С другой стороны, мы хотим знать список подписчиков каждого пользователя. Мы также хотим иметь возможность узнать есть ли у пользователя подписчики или подписан ли он сам на других пользователей. Пользователи будут кликать на ссылку «Подписаться» в профиле любого другого пользователя, чтобы начать его отслеживать. Таким же образом, клик на ссылку «отписаться», будет отменять подписку на пользователя. Последнее требование является возможность пользователя запросить из базы все посты отслеживаемых пользователей.
Итак, если вы думали что это будет быстро и легко, подумайте еще раз!
Связи внутри базы
Мы сказали что хотим иметь для всех пользователей списки подписчиков и подписок. К сожалению, реляционные базы данных не имеют типа @@list@@, всё что у нас есть это таблицы с записями и отношения между записями. У нас уже есть таблица в нашей базе, предствляющая пользователей, осталось придумать отношения зависимости которые смоделируют связи подписчиков/подписок. Это хороший момент, чтобы разобрать три типа отношений в реляционных базах:
Один-ко-многим
Мы уже видели отношение «один-ко-многим» в предыдущей статье о базе данных:
![image](https://habrastorage.org/getpro/habr/post_images/aa3/cff/63d/aa3cff63d109e4418fab6fda82f958ed.png)
Две сущности связаные подобными отношениями это
users
и posts
. Мы говорим что юзер может иметь много постов, а пост имеет только одного юзера. Эти отношения используются в БД с внешним ключем (FK) на стороне «многих». В приведенном выше примере внешним ключ является поле user_id
, добавленное в таблицу posts
. Это поле связывает каждый пост с записью об авторе в таблице пользователей.Понятно, что поле
user_id
обеспечивает прямой доступ к автору данного поста, но что насчет обратной связи? Чтобы связь была полезной, мы должны быть в состоянии получить список постов написаных пользователем. Оказывается поля user_id
в таблице posts
достаточно, чтобы ответить на наш вопрос, так как базы данных имеют индексы, которые позволяют делать такие запросы как «получить все сообщения где user_id равно X».Многие-ко-многим
Отношения «многие-ко-многим» немного сложней. Для примера рассмотрим БД в котором хранятся
students
и teachers
. Мы можем сказать что у студента может быть много преподавателей, а у преподавателя может быть много студентов. Это похоже на два частично совпадающих отношения «один-ко-многим».Для отношений такого типа мы должны быть в состоянии запросить БД и получить список учителей, которые учат студента, и список студентов в классе учителя. Оказывается это довольно сложней представить в базе, такое отношение не может быть смоделировано добавлением внешних ключей к уже существующим таблицам.
Реализация отношения «многие-ко-многим» требует использования вспомогательной таблицы, называемую сводной таблицей. Вот, например, как будет выглядеть БД для студентов и преподавателей:
![image](https://habrastorage.org/getpro/habr/post_images/585/415/716/585415716aa54bef9df8dc95fbda52c3.png)
Хоть это может показаться непростым, но сводная таблица может ответить на многие наши вопросы, такие как:
- У кого учится студент S?
- Кого учит учитель T?
- Как много студентов у учителя T?
- Как много учителей у студента S?
- Учитель T учит студента S?
- Студент S посещает класс учителя T?
Один-к-одному
Отношения «один-к-одному» это частный случай отношений «один-ко-многим». Представление очень похоже, но содержит запрет на добавление больше чем одной связи, чтобы не превратиться в «один-ко-многим». Хотя есть случаи, в которых этот тип отношений полезен, это случается не так часто, как в случае двух других типов, так как в ситуации связи двух таблиц отношением «один-к-одному» может иметь смысл объединить таблицы в одну.
Представление подписчиков\подписок
Из приведенных выше отношений мы легко можем определить, что нам подходит модель данных «многие-ко-многим», потому что пользователь может следить за множеством других пользователей и пользователь может иметь много подписчиков. Но есть особенность. Мы хотим представлять пользователей подписанных на других пользователей, но мы имеем только одну таблицу пользователей. Итак, что мы должны использовать как вторую сущность в отношениях «многие-ко-многим»?
Конечно, второй сущностью в отношениях будет та же самая таблица пользователей. Отношения в которых экземпляры сущности связаны с дргими экземплярами той же сущности называются самоссылающимися отношениями, и это именно то, что нам нужно.
Это диаграмма наших «многие-ко-многим» отношений:
![image](https://habrastorage.org/getpro/habr/post_images/649/c7a/a48/649c7aa489c545ac79863698b3d348a1.png)
Таблица
followers
это сводная таблица. Оба внешних ключа указывают на таблицу user
, т.к. мы связали таблицу с собой же. Каждая запись в этой таблицу представляет одну связь между подписанным пользователем и тем, на кого он подписан. Как в примере студентов и учителей, такая конфигурация как эта позволяет нашей базе данных ответить на все вопросы касательно подписчиков и их подписок, которые нам нужны. Всё довольно просто.Модель БД
Изменения в нашей модели будут не очень большие. Мы начнем с добавления таблицы @@followers@@ (файл @@app/models.py@@):
followers = db.Table('followers',
db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
)
Это прямая трансляция связей таблиц из нашей диаграммы. Обратите внимание, что мы не объявляли эту таблицу в качестве модели, как мы делали для
users
и posts
. Так как это вспомогательная таблица, которая не имеет данных, кроме внешних ключей, мы будем использовать низкоуровневое API flask-sqlalchemy для создания таблицы без создания ее модели.Далее мы описываем отношения многие-ко-многим в таблице
users
(файл app/models.py
)class User(db.Model):
id = db.Column(db.Integer, primary_key = True)
nickname = db.Column(db.String(64), unique = True)
email = db.Column(db.String(120), index = True, unique = True)
role = db.Column(db.SmallInteger, default = ROLE_USER)
posts = db.relationship('Post', backref = 'author', lazy = 'dynamic')
about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime)
followed = db.relationship('User',
secondary = followers,
primaryjoin = (followers.c.follower_id == id),
secondaryjoin = (followers.c.followed_id == id),
backref = db.backref('followers', lazy = 'dynamic'),
lazy = 'dynamic')
Конфигурация отношений нетривиальна и требует некоторых пояснений. Мы используем функцию
db.relationship
, чтобы определить связь между таблицами, как мы это делали в предыдущей статье. Мы свяжем экземпляр User
с другим экземпляром User
, и для соглашения скажем, что в паре связанных пользователей, левый пользователь подписан на правого пользователя. Как видно из описания отношений, левую сторону мы назвали followed
, потому что, когда мы запрашиваем отношения левой стороны, мы получим список подписчиков. Давайте рассмотрим все аргументы db.relationship
по одному:'User'
правая сторона сущности в отношениях (левая сторона это родительский класс). Когда мы определяем самоссылающиеся отношения мы используем один и тот же класс на обоих сторонах.-
secondary
указывает на сводную таблицу, используемую в этих отношениях. -
primaryjoin
описывает связь левой стороны сущности со сводной таблицей. Обратите внимание, что таблицаfollowers
не является моделью, поэтому чтобы добраться до имени поля используется немного странный синтаксис. -
secondaryjoin
описывает связь правой стороны со сводной таблицей. -
backref
описывает как эти отношения будут доступны с правой стороны сущности. Мы сказали что для данного пользователя запросfollowed
возвращал все юзеров с правой стороны, которые связаны с пользователем слева. Обратная ссылка называетсяfollowers
и она вернет всех пользователей слева, который связаны с пользователем справа. Дополнительный аргументlazy
указывает на способ выполнения данного запроса. Этот режим указывает не выполнять запрос, пока явно об этом не попросили. Это полезно для повышения производительности, а также потому, что мы сможем получить этот запрос и изменить его перед выполнением. Более подробно мы разберем это позднее. - параметр
lazy
немного похож на параметр с таким же названием вbackref
, но этот относится к запросу, а не к обратной связи.
Не отчаивайтесь, если это тяжело для понимания. Мы увидим, как использовать эти запросы, и тогда всё станет понятней. Так как мы сделали обновления базы данных, теперь мы можем создать новую миграцию:
./db_migrate.py
Этим мы завершили изменения базы данных. Осталось совсем немного покодить.
Добавление и удаление подписчиков
Для поддержки повторного использования кода, мы реализуем функционал подписки\подписчики внутри модели
User
и не станем выносить его в представление. Таким образом мы можем использовать эту функцию для текущего приложения (ссылаться из представления) и использовать ее в тестировании. В принципе, всегда лучше переместить логику приложения из представления в модель, это значительно упрощает тестирование. Вы должны держать представления как можно проще, потому что их тяжелей тестировать в автоматическом режиме.Ниже приведем код для добавления и удаления подписчиков, определенный в виде методов модели
User
(файл app/models.py
):class User(db.Model):
#...
def follow(self, user):
if not self.is_following(user):
self.followed.append(user)
return self
def unfollow(self, user):
if self.is_following(user):
self.followed.remove(user)
return self
def is_following(self, user):
return self.followed.filter(followers.c.followed_id == user.id).count() > 0
Благодаря силе SQL Alchemy, которая делает много работы, эти методы являются удивительно простыми. Мы просто добавляем или удаляем элементы, а SQLAlchemy делает всю остальную работу. Методы
follow
и unfollow
определены так, что они возвращают объект, когда всё прошло успешно, и None когда не удалось завершить операцию. Когда объект возвращается, он должен быть добавлен в базу и сделан commit.Метод
is_following
делает довольно много, несмотря на одну строчку кода. Мы принимаем запрос, который возвращает все пары (follower, followed)
с участием пользователя и фильтруем их по столбцу followed
. Из filter()
возвращается модифицированый запрос, пока еще не выполненный. Таким образом мы вызовем count()
на этот запрос и теперь этот запрос будет выполнен и вернет количество найденых записей. Если мы получим хоть одну, то мы будем знать что связи есть. Если мы не получим ничего, то мы будем знать что связей нет.Тестирование
Давайте напишем тест для нашего кода (файл
tests.py
):class TestCase(unittest.TestCase):
#...
def test_follow(self):
u1 = User(nickname = 'john', email = 'john@example.com')
u2 = User(nickname = 'susan', email = 'susan@example.com')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
assert u1.unfollow(u2) == None
u = u1.follow(u2)
db.session.add(u)
db.session.commit()
assert u1.follow(u2) == None
assert u1.is_following(u2)
assert u1.followed.count() == 1
assert u1.followed.first().nickname == 'susan'
assert u2.followers.count() == 1
assert u2.followers.first().nickname == 'john'
u = u1.unfollow(u2)
assert u != None
db.session.add(u)
db.session.commit()
assert u1.is_following(u2) == False
assert u1.followed.count() == 0
assert u2.followers.count() == 0
После добавления этого теста в тест-фреймворк мы можем запустить набор тестов командой:
./tests.py
И если все работает все тесты будут успешно пройдены.
Запросы к БД
Наша текущая модель БД поддерживает большинство требований, которые мы перечислили в начале. То, чего нам не хватает, на самом деле, реализовать трудней всего. На главной странице сайта будут показаны сообщения, написаные всеми людьми, за которыми следит наш залогиненый пользователь, поэтому нам нужен запрос, который возвращает все эти сообщения.
Наиболее очевидным решением является запрос, который даст список отслеживаемых людей, который мы уже можем сделать. Тогда для каждго из этих пользователей мы выполним запрос, чтобы получить его сообщения. Когда у нас будут все сообщения, мы можем объединить их в единый список и отсортировать по времени. Звучит хорошо? Не совсем.
Этот подход имеет пару проблем. Что произойдет, если пользователь отслеживает тысячу человек? Нам придется выполнить тысячу запросов к базе только чтобы собрать сообщения. И теперь у нас есть тысячи списков в памяти, которые мы должны отсортировать и объединить. На нашей главной странице реализована нумерация страниц, поэтому мы не будем показывать все доступные сообщения, а только первые 50, и ссылки по которым можно посмотреть следующие 50. Если мы собираемся показывать сообщения упорядоченые по дате, как мы узнаем какие из них являются последними 50 сообщениями всех пользователей, если сначала не получим все сообщения и не отсортируем их.
На самом деле это ужасное решение, которое очень плохо масштабируется. Хоть этот способ по сбору и сортировке хоть как-то работает, он недостаточно эффективен. Это именно та работа в которой реляционные БД преуспевают. База данных содержит индексы, которые позволяют ей выполнять запросы и сортировки гораздо эффективней, чем мы можем сделать это со своей стороны.
Мы должны придумать запрос, который выражает то, что мы хотим получить, а база данных вычислит каким образом более эффективно извлечь необходимую нам информацию.
Чтобы развеять тайну, вот запрос, который сделает то, что нам нужно. К сожалению это еще один перегруженый однострочник, который мы добавим к модели пользователя (файл
app.models.py
):class User(db.Model):
#...
def followed_posts(self):
return Post.query.join(followers, (followers.c.followed_id == Post.user_id)).filter(followers.c.follower_id == self.id).order_by(Post.timestamp.desc())
Попробуем расшифровать этот запрос шаг за шагом. Здесь 3 части: join, filter, и order_by.
Joins
Чтобы понять что делает операция join, давайте рассмотрим пример. Предположим, у нас есть таблица
User
с следующим содержанием:User | |
---|---|
id | nickname |
1 | john |
2 | susan |
3 | mary |
4 | david |
Другие поля таблицы не отображены, чтобы не усложнять пример.
Давайте предположим что наша сводная таблица говорит что пользователь «john» подписан на «susan» и «david», пользователь «susan» подписана на «mary» и «mary» подписана на «david». Тогда сводная таблица будет выглядеть примерно так:
followers | |
---|---|
follower_id | followed_id |
1 | 2 |
1 | 4 |
2 | 3 |
3 | 4 |
И в заключение, наша таблица
Post
содержит один пост от каждого пользователя:Post | ||
---|---|---|
id | text | user_id |
1 | post from susan | 2 |
2 | post from mary | 3 |
3 | post from david | 4 |
4 | post from john | 1 |
Здесь тоже убраны некоторые поля, чтобы не усложнят пример.
Ниже приводится часть нашего запроса с join изолированная от остальной части:
Post.query.join(followers,
(followers.c.followed_id == Post.user_id))
Операция
join
вызывается на таблице Post
. Есть два аргумента, первый тот другая таблица, в нашем случае followers
. Второй аргумент указывает по каким полям соединять таблицу. Операция join
сделает временную таблицу с данными из Post
и followers
слитые по указанному условию.В этом примере мы хотим чтобы поля
followed_id
таблицы followers
соответствовали полю user_id
таблицы Post
.Для выполнения данного слияния мы берем каждую запись из таблицы
Post
(левая часть join) и присоединяем поля из записи в таблице followers
(правая часть join) которые соответствуют условию. Если запись не соответствует условию, то она не попадает в таблицу.Результат выполнения join на нашем примере в этой временной таблице:
Post | followers | |||
---|---|---|---|---|
id | text | user_id | follower_id | followed_id |
1 | post from susan | 2 | 1 | 2 |
2 | post from mary | 3 | 2 | 3 |
3 | post from david | 4 | 1 | 4 |
3 | post from david | 4 | 3 | 4 |
Заметьте как сообщение с user_id=1 было удалено из join, потому что нет записей в таблице подписчиков что имеется followed_id=1. Также обратите внимание, что сообщение с user_id=4 появляется дважды, потому что таблица подписчиков имеет два вхождения с followed_id=4.
Filters
Операция join дала нам список сообщений людей, за которыми кто-то следит, не уточнив, кто является подписчиком. Нас интересует подмножество этого списка, в котором только те сообщения, которые отслеживает один конкретный пользователь. Так что мы будем фильтровать эту таблицу по подписчику. Часть запроса с фильтром будет такая:
filter(followers.c.follower_id == self.id)
Помните что запрос выполняется в контексте нашего целевого пользователя, поэтому метод self.id класса User в этом контексте выдает id интересующего нас пользователя. С помощью этого фильтра мы говорим базе данных, что хотим оставить только те записи из таблицы, созданной с помощью join в которых наш пользователь указан как подписчик. Продолжая наш пример, если запросим пользователся с id=1, тогда мы придем к другой временной таблице:
Post | followers | |||
---|---|---|---|---|
id | text | user_id | follower_id | followed_id |
1 | post from susan | 2 | 1 | 2 |
3 | post from david | 4 | 1 | 4 |
И это как раз те посты, которые нам нужны!
Помните что запрос был выполнен на классе Post, так что даже если мы закончим во временной таблице не относящейся ни к одной модели, результат будет включен в эту временную таблицу, без дополнительных столбцов добавленых операцией join.
Sorting
Последним шагом процесса является сортировка результатов по нашим критериям. Часть запроса, которая это делает выглядит так:
order_by(Post.timestamp.desc())
Здесь мы говорим, что результаты должны быть отсортированы по полю
timestamp
в порядке убывания, так что первым будет самый последний пост.Существует только одна незначительная деталь, которая может улучшить наш запрос. Когда пользователи читают посты на которые подписаны, они возможно захотят увидеть свои собственные посты в ленте, и было бы хорошо включить их в результат запроса.
Есть простой способ это сделать, который не требует никаких изменений! Мы просто просто убедимся что каждый пользователь добавлен в базу в качестве своего же подписчика и эта маленькая проблема больше не будет нас заботить. В заключение нашего долгого обсуждения запросов, давайте напишем юнит-тест для нашего запроса (файл tests.py):
#...
from datetime import datetime, timedelta
from app.models import User, Post
#...
class TestCase(unittest.TestCase):
#...
def test_follow_posts(self):
# make four users
u1 = User(nickname = 'john', email = 'john@example.com')
u2 = User(nickname = 'susan', email = 'susan@example.com')
u3 = User(nickname = 'mary', email = 'mary@example.com')
u4 = User(nickname = 'david', email = 'david@example.com')
db.session.add(u1)
db.session.add(u2)
db.session.add(u3)
db.session.add(u4)
# make four posts
utcnow = datetime.utcnow()
p1 = Post(body = "post from john", author = u1, timestamp = utcnow + timedelta(seconds = 1))
p2 = Post(body = "post from susan", author = u2, timestamp = utcnow + timedelta(seconds = 2))
p3 = Post(body = "post from mary", author = u3, timestamp = utcnow + timedelta(seconds = 3))
p4 = Post(body = "post from david", author = u4, timestamp = utcnow + timedelta(seconds = 4))
db.session.add(p1)
db.session.add(p2)
db.session.add(p3)
db.session.add(p4)
db.session.commit()
# setup the followers
u1.follow(u1) # john follows himself
u1.follow(u2) # john follows susan
u1.follow(u4) # john follows david
u2.follow(u2) # susan follows herself
u2.follow(u3) # susan follows mary
u3.follow(u3) # mary follows herself
u3.follow(u4) # mary follows david
u4.follow(u4) # david follows himself
db.session.add(u1)
db.session.add(u2)
db.session.add(u3)
db.session.add(u4)
db.session.commit()
# check the followed posts of each user
f1 = u1.followed_posts().all()
f2 = u2.followed_posts().all()
f3 = u3.followed_posts().all()
f4 = u4.followed_posts().all()
assert len(f1) == 3
assert len(f2) == 2
assert len(f3) == 2
assert len(f4) == 1
assert f1 == [p4, p2, p1]
assert f2 == [p3, p2]
assert f3 == [p4, p3]
assert f4 == [p4]
Этот тест имеет много кода предварительной настройка, но код самого тестирования довольно короткий. Сначала мы проверяем, что число отслеживаемых постов возвращаемых для каждого пользователя равно ожидаемому. Затем для каждого пользователя мы проверяем что были возвращены правильные посты и они пришли в правильном порядке (обратите внимание, что мы вставляли сообщения с временными метками гарантирующими всегда один и тот же порядок).
Обратите внимание на использование метода followed_post(). Этот метод возвращает объект query, а не результат. Так же работает lazy=«dynamic» в отношениях ДБ.
Это всегда хорошая идея вернуть объект вместо результата, потому что дает вызывающему возможность дополнять запрос перед выполнением.
Есть несколько методов в объекте query которые вызывают выполнение запроса. Мы видели, что count() выполняет запрос и возвращает количество результатов, отбросив сами данные. Мы также использовали first() чтобы вернуть первый результат в списке, а остальные отбросить. В тесте мы использовали метод all() чтобы получить массив со всеми результатами.
Возможные улучшения
Мы сейчас реализовали все необходимые функции подписок, но есть несколько способов улучшить наш дизайн и сделать его более гибким. Все социальные сети, которые мы так любим ненавидеть поддерживают подобные пути связи пользователей, но они имеют больше возможностей для управления информацией. Например, нет возможности блокировать подписчиков. Это добавить еще один слой сложности к нашим запросам, так как мы теперь должны не только выбрать пользователей, но еще и отсеять посты тех пользователей, которые заблокировали нас. Как бы это реализовать?
Простой способ это еще одна самоссылающаяся таблица с отношением многие-ко-многим для записи кто кого блокирует, а еще один join+filter в запросе, который возвращает отслеживаемые посты. Другой популярной возможностью соцсетей является возможность группировать подписчиков в списки, чтобы потом делиться с каждой группой своей информацией. Это также требует дополнительных связей и добавляет сложности к запросам.
У нас не будет этих функций в микроблоге, но если это вызовет достаточный интерес, я буду счастлив написать статью на эту тему. Дайте мне знать в комментариях!
Приводим дела в порядок
Мы сегодня достаточно продвинулись. Но хотя мы и решили проблемы с настройкой базы данных и запросами, мы не включили новый функционал в наше приложение. К счастью для нас, с этим нет никаких проблем. Нам просто нужно исправить функции представления и шаблоны для вызова новых методов в модели User, когда это необходимо. Так давайте сделаем это.
Делаем себя своим же подписчиком.
Мы решили отметить всех пользователей подписаными на самих себя, чтобы они могли видеть в ленте свои посты.
Мы собираемся сделать это в точке, где пользователям присваиваются первые настройки аккаунта, в обработчике after_login для OpenID( файл 'app/views.py'):
@oid.after_login
def after_login(resp):
if resp.email is None or resp.email == "":
flash('Invalid login. Please try again.')
return redirect(url_for('login'))
user = User.query.filter_by(email = resp.email).first()
if user is None:
nickname = resp.nickname
if nickname is None or nickname == "":
nickname = resp.email.split('@')[0]
nickname = User.make_unique_nickname(nickname)
user = User(nickname = nickname, email = resp.email, role = ROLE_USER)
db.session.add(user)
db.session.commit()
# make the user follow him/herself
db.session.add(user.follow(user))
db.session.commit()
remember_me = False
if 'remember_me' in session:
remember_me = session['remember_me']
session.pop('remember_me', None)
login_user(user, remember = remember_me)
return redirect(request.args.get('next') or url_for('index'))
Ссылки подписаться и отписаться
Далее мы определим функции представления подписки и отписки (файл app/views.py):
@app.route('/follow/<nickname>')
@login_required
def follow(nickname):
user = User.query.filter_by(nickname = nickname).first()
if user == None:
flash('User ' + nickname + ' not found.')
return redirect(url_for('index'))
if user == g.user:
flash('You can\'t follow yourself!')
return redirect(url_for('user', nickname = nickname))
u = g.user.follow(user)
if u is None:
flash('Cannot follow ' + nickname + '.')
return redirect(url_for('user', nickname = nickname))
db.session.add(u)
db.session.commit()
flash('You are now following ' + nickname + '!')
return redirect(url_for('user', nickname = nickname))
@app.route('/unfollow/<nickname>')
@login_required
def unfollow(nickname):
user = User.query.filter_by(nickname = nickname).first()
if user == None:
flash('User ' + nickname + ' not found.')
return redirect(url_for('index'))
if user == g.user:
flash('You can\'t unfollow yourself!')
return redirect(url_for('user', nickname = nickname))
u = g.user.unfollow(user)
if u is None:
flash('Cannot unfollow ' + nickname + '.')
return redirect(url_for('user', nickname = nickname))
db.session.add(u)
db.session.commit()
flash('You have stopped following ' + nickname + '.')
return redirect(url_for('user', nickname = nickname))
Это должно быть понятно, но стоит обратить внимание на проверки в которых мы стараемся предотвратить ошибку и пробуем предоставляем сообщение пользователю, когда проблема всё же произошла. Теперь у нас есть функции представления, поэтому мы можем подключить их. Ссылки чтобы подписаться или отписаться будут доступны на странице профиля каждого пользователя (файл app/templates/user.html):
<!-- extend base layout -->
{% extends "base.html" %}
{% block content %}
<table>
<tr valign="top">
<td><img src=""></td>
<td>
<h1>User: {{user.nickname}}</h1>
{% if user.about_me %}<p>{{user.about_me}}</p>{% endif %}
{% if user.last_seen %}<p><i>Last seen on: {{user.last_seen}}</i></p>{% endif %}
<p>{{user.followers.count()}} followers |
{% if user.id == g.user.id %}
<a href="{{url_for('edit')}}">Edit your profile</a>
{% elif not g.user.is_following(user) %}
<a href="{{url_for('follow', nickname = user.nickname)}}">Follow</a>
{% else %}
<a href="{{url_for('unfollow', nickname = user.nickname)}}">Unfollow</a>
{% endif %}
</p>
</td>
</tr>
</table>
<hr>
{% for post in posts %}
{% include 'post.html' %}
{% endfor %}
{% endblock %}
В строке в которой была ссылка «Edit» мы сейчас покажем количество подписчиков, которые есть у пользователя и одну из трех возможных ссылок:
- если профиль принадлежит вошедшему систему пользователю, то будет видна кнопка «Edit»
- иначе, если не подписаны на пользователся «Подписаться»
- иначе ссылка «Отписаться»
Сейчас вы можете запустить приложение, создать несколько пользователей, войдя с различных аккаунтов OpenID и поиграть с новыми возможностями.
Все, что осталось это показать посты отслеживаемых пользователей на главной странице, но у нас всё еще отсутствует важная часть головоломки, поэтому придется подождать до следующей главы.
Заключительные слова
Мы реализовали большой кусок нашего приложения сегодня.
Тема связей в БД и запросов является довольно сложной, так что если есть какие-либо вопросы, Вы можете отправить ваши вопросы в комментариях ниже.
В следующей статье мы рассмотрим удивительный мир нумерации страниц, и мы будем, наконец, получать посты из базы данных.
Для ленивых копипастеров ниже ссылка на обновленные исходники нашего микроблога:
Скачать microblog-0.8.zip.
Как всегда, архив не содержит базы данных или виртуального окружения flask. Предыдущие статьи объясняют как развернуть их.
Еще раз спасибо то что читаете мой туториал. Увидимся в следующий раз!
Miguel