Как стать автором
Обновить

Атом — минимальный кирпичик реактивного приложения

Время на прочтение 15 мин
Количество просмотров 46K
Здравствуйте, меня зовут Дмитрий Карловский и я… клиент-сайд разработчик. За плечами у меня 8 лет поддержки самых различных сайтов и веб-приложений: от никому не известных интернет-магазинов, до таких гигантов как Яндекс. И всё это время я не только фигачу в продакшн, но и точу топор, чтобы быть на самом острие технологий. А теперь, когда вы знаете, что я не просто хрен с горы, позвольте рассказать вам про один архитектурный приём, которым я пользуюсь последний год.

Данная статья знакомит читателя с абстракцией «атом», предназначенной для автоматизации слежения за зависимостями между переменными и эффективного обновления их значений. Атомы могут быть реализованы на любом языке, но примеры в статье будут на javascript.

Осторожно: чтение может вызвать вывих мозга, приступ холивара, а также бессонные ночи рефакторинга.

От простого к сложному


Эта глава вкратце демонстрирует типичную эволюцию достаточно простого приложения, постепенно подводя читателя к концепции атома.

Давайте представим себе для начала такую простую задачу: нужно написать пользователю приветственное сообщение. Реализовать это весьма не сложно:

	this.$foo = {}
	
	$foo.message = 'Привет, пользователь!'
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Но было бы не плохо обращаться к пользователю по имени. Допустим, имя пользователя у нас сохранено в localStorage, тогда реализация будет чуть сложнее:

	this.$foo = {}
	
	$foo.userName = localStorage.userName
	
	$foo.message = 'Привет, ' + $foo.userName + '!'
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Но постойте, вычисление userName и message происходит при инициализации, но что если к моменту вызова sayHello его имя уже поменяется? Получается мы поприветствуем его по старому имени, что не очень хорошо. Поэтому давайте перепишем код так, чтобы вычисление сообщения производилось только тогда, когда его действительно нужно будет показать:

	this.$foo = {}
	
	$foo.userName = function( ){
		return localStorage.userName
	}
	
	$foo.message = function( ){
		return 'Привет, ' + $foo.userName() + '!'
	}
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message()
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Заметьте, что нам пришлось поменять интерфейс полей message и userName — теперь в них хранятся не сами значения, а функции, которые их возвращают.

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

Мы могли бы спрятать вызов функции используя Object.defineProperty:

	this.$foo = {}
	
	Object.defineProperty( $foo, "userName", {
		get: function( ){
			return localStorage.userName
		} 
	})
	
	Object.defineProperty( $foo, "message", {
		get: function( ){
			return 'Привет, ' + $foo.userName + '!'
		} 
	})
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

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

* IE8 поддерживает Object.defineProperty только для dom-узлов.
* Функции можно выстраивать в цепочки вида $foo.title( 'Hello!' ).userName( 'Anonymous' ).
* Функцию можно передать в качестве колбэка куда-нибудь: $foo.userName.bind( $foo ) — при этом передано будет свойство целиком (и геттер, и сеттер).
* Функция в своих полях может хранить различную дополнительную информацию: от глобального идентификатора до параметров валидации.
* Если обратиться к несуществующему свойству, то возникнет исключение, вместо молчаливого возвращения undefined.

Но что если имя пользователя поменяется уже после того, как мы показали сообщение? По хорошему, надо отследить этот момент и перерисовать сообщение:

	this.$foo = {}
	
	$foo.userName = function( ){
		return localStorage.userName
	}
	
	$foo.message = function( ){
		return 'Привет, ' + $foo.userName() + '!'
	}
	
	$foo._sayHello_listening = false
	$foo.sayHello = function(){
		if( !$foo._sayHello_listening ){
			window.addEventListener( 'storage', function( event ){
				if( event.key === 'userName' ) $foo.sayHello()
			}, false )
			this._sayHello_listening = true
		}
		document.body.innerHTML = $foo.message()
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

И тут мы совершили страшный грех — реализация метода sayHello, внезапно, знает о внутренней реализации свойства userName (знает откуда оно получает своё значение). Стоит заметить, что в примерах они находятся рядом лишь для наглядности. В реальном приложении такие методы будут находиться в разных объектах, код будет лежать в разных файлах, а поддерживаться он будет разными людьми. Поэтому этот код следует переписать так, чтобы одно свойство могло подписываться на изменения другого через его публичный интерфейс. Дабы не переусложнять код, воспользуемся реализацией pub/sub из jQuery:

	this.$foo = {}
	$foo.bus = $({})
	
	$foo._userName_listening = false
	$foo.userName = function( ){
		if( !this._userName_listening ){
			window.addEventListener( 'storage', function( event ){
				if( event.key !== 'userName' ) return
				$foo.bus.trigger( 'changed:$foo.userName' )
			}, false )
			this._userName_listening = true
		}
		
		return localStorage.userName
	}
	
	$foo._message_listening = false
	$foo.message = function( ){
		if( !this._message_listening ){
			$foo.bus.on( 'changed:$foo.userName', function( ){
				$foo.bus.trigger( 'changed:$foo.message' )
			} )
			this._message_listening = true
		}
		
		return 'Привет, ' + $foo.userName() + '!'
	}
	
	$foo._sayHello_listening = false
	$foo.sayHello = function(){
		if( !this._sayHello_listening ){
			$foo.bus.on( 'changed:$foo.message', function( ){
				$foo.sayHello()
			} )
			this._message_listening = true
		}
		
		document.body.innerHTML = $foo.message()
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

В данном случае общение между свойствами реализовано через единую шину $foo.bus, но это может быть и россыпь отдельных EventEmitter-ов. Принципиально будет та же самая схема: если одно свойство зависит от другого, то оно должно где-то подписаться на его изменения, а если само оно меняется, то нужно разослать уведомление о своём изменении. Кроме того, в этом коде вообще не предусмотрен вариант отписки, когда слежение за значением свойства уже не требуется. Давайте введём свойство showName в зависимости от состояния которого мы будем показывать или не показывать имя пользователя в приветственном сообщении. Особенность такой, достаточно типичной, постановки задачи заключается в том, что если showName='false', то текст сообщения не зависит от значения userName и поэтому на это свойство нам не стоит подписываться. Более того, если мы уже на него подписались, потому что ранее было showName='true', то нам нужно отписаться от userName, после получения showName='false'. А чтобы совсем жизнь раем не казалась, добавим ещё такое требование: значения получаемых из localStorage свойств должны кэшироваться, чтобы лишний раз его не трогать. Реализация по аналогии с предыдущим кодом получится уже слишком объёмной для этой статьи, поэтому воспользуемся несколько более компактным псевдокодом:

	property $foo.userName :
		subscribe to localStorage
		return string from localStorage
		
	property $foo.showName :
		subscribe to localStorage
		return boolean from localStorage
		
	property $foo.message :
		subscribe to $foo.showName
		switch
			test $foo.showName
			when true
				subscribe to $foo.userName
				return string from $foo.userName
			when false
				unsubscribe from $foo.userName
				return string
	
	property $foo.sayHello :
		subscribe to $foo.message
		put to dom string from $foo.message
	
	function start : call $foo.sayHello

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

Тезис 2: Вовремя отписывайтесь от невлияющих зависимостей иначе рано или поздно приложение начнёт тормозить.

Описанная выше архитектура называется Event-Driven. И это ещё наименее ужасный её вариант — в более распространённом случае подписка, отписка и несколько способов вычисления значения разбросаны по разным местам проекта. Event-Driven архитектура очень хрупкая, потому что приходится вручную следить за своевременными подписками и отписками, а человек — существо ленивое и не сильно внимательное. Поэтому лучшее решение — минимизировать влияние человеческого фактора, скрыв от программиста механизм распространения событий, позволив ему тем самым сконцентрироваться на описании того, как одни данные получаются из других.

Давайте упростим код, оставив лишь минимально необходимую информацию о зависимостях:

	property userName : return string from localStorage
		
	property showName : return boolean from localStorage
		
	function $foo.message :
		switch
			test $foo.showName
			when true return string from $foo.userName
			when false return string
	
	property $foo.sayHello : put to dom string from $foo.message
	
	function start : call $foo.sayHello

Зоркие читатели уже скорее всего заметили, что, после избавления от ручной подписки-отписки, описания свойств представляют из себя так называемые «чистые функции». И действительно, у нас получилась FRP (Функциональная Реактивная Парадигма). Давайте разберем каждый термин подробней:

Функциональная — каждая переменная описывается в виде функции, которая генерирует её значение на основе значений других переменных.
Реактивная — изменение одной переменной автоматически приводит к обновлению значений всех зависящих от неё переменных.
Парадигма — программисту требуется несколько повернуть мышление, чтобы понять и принять принципы построения приложения.

Как видно, все описанное выше крутится вокруг переменных и зависимостей между ними. Назовём такие frp-переменные «атомами» и сформулируем их основные свойства:

1. Атом хранит в себе ровно одно значение. Этим значением может быть как примитив, так и любой объект в том числе и объект исключения.
2. Атом хранит в себе функцию для вычисления значения на основе других атомов через произвольное число промежуточных функций. Обращения к другим атомам при её исполнении отслеживаются так, чтобы атом всегда имел актуальную информацию о том, какие другие атомы влияют на его состояние, а также о том, состояние каких атомов зависит от его.
3. При изменении значения атома зависимые от него должны обновиться каскадно.
4. Исключительные ситуации не должны нарушать консистентность состояния приложения.
5. Атом должен легко интегрироваться с императивным окружением.
6. Так как чуть ли не каждый слот памяти заворачивается в атом, то реализация атомов должна быть максимально быстрой и компактной.


Проблемы при реализации атомов



1. Поддержание зависимостей в актуальном состоянии

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

Реализуется это весьма просто: в момент старта вычисления одного атома где-нибудь в глобальной переменной запоминается, что он — текущий, а в момент получения значения другого помимо собственно возвращения этого значения осуществляется их линковка друг с другом. То есть у каждого атома помимо слота для собственно значения должно быть два множества: ведущих атомов (masters) и ведомых (slaves).

С отлинковкой всё несколько сложнее: в момент старта нужно подменить множество ведущих атомов на пустое, а по завершении вычисления сравнить полученное множество с предыдущим и отлинковать тех, кто не вошли в новое множество.

Примерно так и работает автотрекинг зависимостей в KnockOutJS и MeteorJS.

Но как атомы узнают, когда надо заново запускать вычисление значения? Об этом далее.


2. Каскадное непротиворечивое обновление значений

Казалось бы, что может быть проще? Сразу после изменения значения пробегаемся по зависимым атомам и инициируем их обновление. Именно так и поступает KnockOutJS и именно поэтому он тормозит при массовых обновлениях. Если один атом (A) зависит от, например, двух других (B,C), то если мы последовательно изменим их значения, то значение атома A будет вычислено дважды. А теперь представьте, что зависит он не от двух, а от двух тысяч атомов и каждое вычисление занимает хотя бы 10 миллисекунд.

image

В то время как для KnockOutJS разработчики в узких местах расставляют throttl-инги и debounce-еры, разработчики MeteorJS подошли к проблеме более системно: сделали отложенный вызов пересчёта зависимых атомов вместо немедленного. Для описанного выше случая атом A пересчитает своё значение ровно один раз причём сделает это по окончании текущего обработчика события, то есть после всех внеснных нами изменений в атомы B, C и любые другие.

Но это на самом деле не полное решени проблемы — она всплывает вновь, когда глубина зависимостей атомов становится больше 2. Проиллюстрирую это простым примером: атом A зависит от атомов B и C, а C в свою очередь зависит от D. В случае, если мы последовательно изменим атомы B и D, то отложенно будут пересчитаны атомы A и C и, если атом C при этом изменит своё значение, то снова будет запущено отложенное вычисление значения A. Это уже как правило не так фатально для скорости, но если вычисление A — довольно длительная операция, то её удвоение может выстрелить в самом неожиданном месте приложения.

image

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

image

3. Обработка исключительных ситуаций

Представим себе такую ситуацию: атомы B и C зависят от A. Атом B начал вычисление значения и обратился к A. A — тоже начал вычислять своё значение, но в этот момент возникла исключительная ситуация — это может быть ошибка в коде или же отсутствие данных — не важно. Главное, что атом A должен запомнить это исключение, но позволить ему всплыть дальше, чтобы и B смог его запомнить или обработать. Почему это так важно? Потому, что когда C начнёт вычисление значение и обратится к A, то происходящие события для него должны быть такими же как и для B: при обращении к A всплыло исключение, которое можно перехватить и обработать, а можно и ничего не делать и тогда исключение должно быть поймано библиотекой реализующей атомы и сохранено в вычисляемом атоме. Если бы атомы не запоминали исключения, то всякое обращение к ним вызывало бы запуск одного и того же кода неизбежно приводя к одному и тому же исключению. Это лишние расходы процессорных ресурсов, так что лучше их кэшировать как и обычные значения.

image

Ещё один, и даже более важный, момент заключается в том, что при каскадном обновлении атомов вычисление значений оных происходит в обратном направлении. Например, атом A зависит от B, а тот зависит от C, а тот вообще от D. При инициализации A начинает вычислять своё значение и обращается к B, тот к C, а тот к D. Но состояние актуализируется в обратном порядке: D, потом C, потом B, и наконец A:

image

Впоследтствии кто-то меняет значение атома D. Тот уведомляет атом C что его значение уже не актуально. Тогда атом C вычисляет своё значение и если оно не равно предыдущему, то уведомляет атом B, который поступая аналогично уведомляет A. Если в какой-то из этих моментов мы не перехватим исключение и как следствие не уведомим зависимые атомы, то у нас получится ситуация, когда приложение находится в противоречивом состоянии: половина приложения содержит новые данные, половина старые, но уверено, что новые, а третья половина вообще упала и никак не может подняться, ожидая изменения данных.

image

4. Циклические зависимости

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

Детектируется это просто: при старте вычисления атом запоминает, что он сейчас вычисляется, а когда к его значению кто-либо обращается он проверяет не находится ли он в состоянии вычисления и если находится — бросает исключение.

5. Асинхронность

Асинхронный код — это всегда проблема, потому что превращает логику в спагетти, за хитросплетениями которой сложно уследить и легко ошибиться. При разработке на javascript приходится постоянно балансировать между простым и понятным синхронным кодом и асинхронными вызовами. Основная проблема асинхронности в том, что она как монада просачивается через интерфейсы: вы не можете написать синхронную реализацию модуля А, а потом незаметно от использующего его модуля В подменить реализацию А на асинхронную. Чтобы произвести такое изменение вам придётся изменить и логику модуля B, и зависящего от него C и D и так далее. Асинхронность — это как вирус, который пробивается через все наши абстракции и выпячивает внутреннюю реализацию наружу.

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

а) Вернуть некое дефолтное значение. Для ведомых атомов это будет выглядеть как «было одно значение и вдруг оно изменилось», но они не смогут понять актуальные данные им показали или не очень. А знать это зачастую необходимо, чтобы, например, показывать пользователю сообщение, что его данные никуда не пропали и вот-вот будут подгружены.

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

в) Честно признаться, что данных нет и вернуть специальное значение означающее отсутствие данных. Для javascript это будет значение undefined. Но в этом случае везде в зависимом коде должна быть правильная обработка этого значения — это достаточно большой объём однотипного кода в котором достаточно легко допустить ошибку, так что выбрав этот путь готовьтесь к то и дело появляющимся Null Pointer Exception.

г) После запуска асинхронной задачи бросить специальное исключение, которое, как было описано выше, каскадно распространится по всем зависимым атомам до тех атомов, где оно может быть обработано. Например, атом, отвечающий за показ списка пользователей может перехватить исключение и вместо того, чтобы молча упасть нарисовать пользователю сообщение «идёт загрузка» или «ошибка загрузки». То есть начиная с какого-то удалённого атома исключительная ситуация становится вполне себе штатной. Преимущество такого способа в том, что обрабатывать отсутствие данных можно только в сравнительно небольшом числе мест кода, а не на всём пути к этим местам. Но тут важно помнить, что при зависимости от нескольких атомов, вычисление остановится после первого же бросившего исключение, а остальные так и не узнают о том, что их данные тоже нужны, хотя все ведущие атомы могли бы запросить свои данные в одном едином запросе. Благо эти моменты легко обнаруживаются по излишнему числу запросов к серверу и сранительно не хитро исправляются, расстановкой try-catch в нужных местах.

6. Интеграция с императивным кодом

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

Условно можно разделить атомы на 3 основных типа по стратегии использования:

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

7. Потребление памяти

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

Вот, что хранит типичный атом:

а) собственно значение
б) объект исключения (может быть совмещен со значением, но не во всех языках)
в) текущее состояние: не актуально, запланировано обновление, идет вычисление, актуально, ошибка…
г) множество ведущих атомов
д) множество ведомых атомов
е) максимальная глубина
ё) число ведомых атомов
ж) идентификатор (для языков, где нет возможности идентифицировать их по указателю)
з) пачку функций, реализующих нужное поведение

Довольно большая плата за точность графа зависимостей. Есть несколько стратегий, по экономии памяти:

а) Снижать точность, храня в одном атоме несколько значений. В результате будет некоторое количество ложных уведомлений, когда поменялись соседние данные, но чтобы это выяснисть нужно запустить вычисление значения. Получится аналог $digest из AngularJS, но только в рамках одного атома и только тогда, когда в атоме реально что-то поменялось, а не только лишь «могло».

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

в) Снижать число атомов-источников. Вместо нескольких источников можно иметь один, как в пункте (а), но доступ к нему производить не напрямую, а через промежуточные атомы, которые получают из источника данные и проверяют только нужную им часть. Кажется мы таким образом делаем только хуже — меняем, например, 10 источников, на 1 источник + 10 промежуточных. Но промежуточные могут самоуничтожаться, когда данные не нужны, при этом не теряя данные находящиеся в источнике, позволяя быстро восстановить эти атомы при необходимости.

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

д) Функции, уточняющие поведение, помещать не в сами атомы, а в субклассы. Если у вас есть пачка атомов, ведущая себя одинаково, то оптимальнее создать для них субкласс, где уточнить поведение, а в инстансах хранить только различающиеся данные.

Эпилог


Подводя итог, давайте систематизируем интерфейсы атомов в виде следующей диаграмы:

image

A — некоторый атом. Внутренний круг — это его состояние. А внешний — граница его интерфейса.
S — ведомая часть приложения, тут могут быть не только атомы, но и всё, что так или иначе зависит от текущего атома.
M — ведущая часть, от которой зависит значение.

Стрелки означают поток данных. Жирные их концы символизируют инициатора взаимодействия. Красным помечены те интерфейсы, поведение которых можно уточнять с помощью пользовательских функций.

А теперь подробнее об интерфейсах:
get — запрос значения. Если значение не актуально, то происходит запуск pull для его актуализации.
pull — та самая функция для вычисления значения атома. Когда он решает обновить своё значение он вызывает именно эту функцию. В ней можно реализовать асинхронный запрос, который потом поместит значение в атом через push интерфейс.
push — установить новое значение атома, но оно будет сохранено не как есть, а будет сначала пропущено через merge интерфейс
merge — сливает новое и текущее значение. Тут может быть нормализация, dirty-checking, валидация.
notify — уведомление ведомых о об изменении.
fail — уведомление ведомых о об ошибке.
set — через это интерфейс ведомый атом может предложить ведущему новое значение и если новое значение после merge отличается от текущего, то вызывается put.
put — по умолчанию делает push, но его назвачение в том, чтобы предложить новое состояние ведущему атому.

На этом пока всё, статья и так получилась довольно длинной и в основном теоретической. В продолжении будет больше практики с использованием javascript библиотеки "$jin.atom". Вы узнаете как она устроена, какие использовались оптимизации, помимо тех, что описаны выше. И конечно же будут практические примеры.

В ожидании продолжения, предлагаю вам попробовать реализовать атомы самостоятельно. Потом будет интересно сравнить наши решения.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+43
Комментарии 36
Комментарии Комментарии 36

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн