Представляем вашему вниманию вторую часть перевода материала, посвящённого особенностям работы с модулями в Python-проектах Instagram. В первой части перевода был дан обзор ситуации и показаны две проблемы. Одна из них касается медленного запуска сервера, вторая — побочных эффектов небезопасных команд импорта. Сегодня этот разговор продолжится. Мы рассмотрим ещё одну неприятность и поговорим о подходах к решению всех затронутых проблем.
Проблема №3: мутабельное глобальное состояние
Взглянем на ещё одну категорию распространённых ошибок.
def myview(request):
SomeClass.id = request.GET.get("id")
Тут мы находимся в функции-представлении и присоединяем атрибут к некоему классу, основываясь на данных, полученных из запроса. Вероятно, вы уже поняли суть проблемы. Дело в том, что классы — это глобальные синглтоны. А мы тут помещаем состояние, зависящее от запроса, в долгоживущий объект. В процессе веб-сервера, который выполняется достаточно долго, это может привести к загрязнению каждого будущего запроса, выполняемого в рамках этого процесса.
То же самое легко может произойти и в тестах. В частности — в тех случаях, когда программисты пытаются пользоваться обезьяньими патчами и при этом не применяют менеджер контекста — вроде mock.patch
. Это может привести уже не к загрязнению запросов, а к загрязнению всех тестов, которые будут выполняться в том же процессе. Это — серьёзная причина ненадёжного поведения нашей системы тестирования. Проблема это значительная, да и предотвратить подобное очень сложно. В результате мы отказались от единой системы тестирования и перешли к схеме изоляции тестов, которую можно описать как «один тест на процесс».
Собственно говоря, это — наша третья проблема. Мутабельное глобальное состояние — это явление, характерное не только для Python. Найти такое можно где угодно. Речь идёт о классах, модулях, о списках или словарях, прикреплённых к модулям или классам, об объектах-синглтонах, созданных на уровне модуля. Работа в такой среде требует дисциплинированности. Для того чтобы не допустить загрязнения глобального состояния во время работы программы, нужны очень хорошие знания Python.
Знакомство со strict-модулями
Одной из глубинных причин наших проблем может быть то, что мы используем Python для решения таких задач, на которые этот язык не рассчитан. В маленьких командах и в маленьких проектах, в том случае, если при использовании Python придерживаться правил, этот язык работает просто замечательно. А нам следовало бы перейти на более строгий язык.
Но наша кодовая база уже переросла тот размер, который позволяет хотя бы говорить о том, чтобы переписать её на другом языке. И, что ещё важнее, несмотря на все проблемы, с которыми мы сталкиваемся, в Python есть много такого, что нам подходит. Нам он даёт больше хорошего, чем плохого. Нашим разработчикам очень нравится этот язык. В результате только от нас зависит то, как заставить Python работать в наших масштабах, и как сделать так, чтобы мы могли бы продолжать работу над проектом по мере его развития.
Поиск решений наших проблем привёл нас к одной идее. Она заключается в использовании strict-модулей.
Strict-модули — это Python-модули нового типа, в начале которых есть конструкция __strict__ = True
. Они реализованы с использованием множества низкоуровневых механизмов расширяемости, которые уже есть в Python. Особый загрузчик модулей разбирает код с использованием модуля ast
, выполняет абстрактную интерпретацию загруженного кода для его анализа, применяет к AST различные трансформации, а затем компилирует AST обратно в байт-код Python, используя встроенную функцию compile
.
Отсутствие побочных эффектов при импорте
Strict-модули накладывают некоторые ограничения на то, что может происходить на уровне модуля. Так, весь код уровня модуля (в том числе — декораторы и функции/инициализаторы, вызываемые на уровне модуля) должен быть чистым, то есть — кодом, лишённым побочных эффектов и не использующим механизмы ввода-вывода. Эти условия проверяются абстрактным интерпретатором с помощью средств статического анализа кода во время компиляции.
Это означает, что использование strict-модулей не приводит к возникновению побочных эффектов при их импорте. Код, выполняемый во время импорта модуля, больше не может привести к возникновению неожиданных проблем. Из-за того, что мы проверяем это на уровне абстрактной интерпретации, используя инструменты, понимающие большое подмножество Python, мы избавляем себя от необходимости в чрезмерном ограничении выразительности Python. Многие виды динамического кода, лишённого побочных эффектов, можно спокойно использовать на уровне модуля. Сюда входят и различных декораторы, и определение констант уровня модуля с помощью списков или генераторов словарей.
Давайте, чтобы было понятнее, рассмотрим пример. Вот правильно написанный strict-модуль:
"""Module docstring."""
__strict__ = True
from utils import log_to_network
MY_LIST = [1, 2, 3]
MY_DICT = {x: x+1 for x in MY_LIST}
def log_calls(func):
def _wrapped(*args, **kwargs):
log_to_network(f"{func.__name__} called!")
return func(*args, **kwargs)
return _wrapped
@log_calls
def hello_world():
log_to_network("Hello World!")
В этом модуле мы можем пользоваться обычными конструкциями Python, включая динамический код, такой, который используется при создании словаря, и такой, который описывает декоратор уровня модуля. При этом обращение к сетевым ресурсам в функциях _wrapped
или hello_world
— это совершенно нормально. Дело в том, что они не вызываются на уровне модуля.
Но если бы мы переместили вызов log_to_network
во внешнюю функцию log_calls
, или если бы попытались использовать декоратор, вызывающий побочные эффекты (вроде @route
из предыдущего примера), или если бы воспользовались вызовом hello_world()
на уровне модуля, то он перестал бы быть правильным strict-модулем.
Как узнать о том, что функции log_to_network
или route
небезопасно вызывать на уровне модуля? Мы исходим из предположения о том, что всё, импортированное из модулей, не являющихся strict-модулями, небезопасно, за исключением некоторых функций из стандартной библиотеки, о которых известно то, что они безопасны. Если модуль utils
является strict-модулем, тогда мы можем положиться на анализ нашего модуля, сообщающий нам о том, безопасна ли функция log_to_network
.
В дополнение к повышению надёжности кода, импорты, лишённые побочных эффектов, устраняют серьёзную преграду к безопасной инкрементной загрузке кода. Это открывает и другие возможности в плане исследования способов ускорения команд импорта. Если код уровня модуля лишён побочных эффектов, это значит, что мы можем безопасно выполнять отдельные инструкции модуля в «ленивом» режиме, по запросу, при доступе к атрибутам модуля. Это куда лучше, чем следование «жадному» алгоритму, при применении которого весь код модуля выполняется заблаговременно. И, учитывая то, что форма всех классов в strict-модуле полностью известна во время компиляции, в будущем мы можем даже попытаться организовать постоянное хранение метаданных модуля (классов, функций, констант), генерируемых при выполнении кода. Это позволит нам организовать быстрый импорт неизменившихся модулей, не требующий повторного выполнения байт-кода уровня модуля.
Иммутабельность и атрибут __slots__
Strict-модули и классы, объявленные в них, иммутабельны после их создания. Модули делаются иммутабельными с помощью внутренней трансформации тела модуля в функцию, в которой доступ ко всем глобальным переменным организован через переменные замыкания. Эти изменения серьёзно уменьшили возможности по случайному изменению глобального состояния, хотя с мутабельным глобальным состоянием всё ещё можно работать в том случае, если решено будет пользоваться им через мутабельные контейнеры уровня модуля.
Члены классов, объявленных в strict-модулях, кроме того, должны объявляться в __init__
. Они автоматически записываются в атрибут __slots__
в ходе трансформации AST, выполняемой загрузчиком модуля. В результате позже нельзя уже прикрепить дополнительные атрибуты к экземпляру класса. Вот подобный класс:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
В ходе трансформации AST, выполняемой при обработке strict-модулей, будут обнаружены операции присваивания значений атрибутам name
и age
, выполняемые в __init__
, и к классу будет прикреплён атрибут вида __slots__ = ('name', 'age')
. Это предотвратит добавление в экземпляр класса любых других атрибутов. (Если же используются аннотации типов, то мы учитываем и сведения о типах, имеющихся на уровне класса, такие, как name: str
, и также добавляем их в список слотов).
Описанные ограничения не только делают код надёжнее. Они помогают ускорить выполнение кода. Автоматическая трансформация классов с добавлением в них атрибута __slots__
увеличивает эффективность использования памяти при работе с этими классами. Это позволяет избавиться от поиска по словарю при работе с отдельными экземплярами классов, что ускоряет доступ к атрибутам. Кроме того, мы можем продолжить оптимизацию этих паттернов во время выполнения Python-кода, что позволит нам ещё сильнее улучшить нашу систему.
Итоги
Strict-модули — это всё ещё технология экспериментальная. У нас есть рабочий прототип, мы находимся на ранних стадиях развёртывания этих возможностей в продакшне. Мы надеемся, что после того, как наберёмся достаточно опыта в использовании strict-модулей, сможем рассказать о них подробнее.
Уважаемые читатели! Как вы думаете, пригодятся ли в вашем Python-проекте те возможности, которые предлагают strict-модули?
Автор: ru_vds