Введение в gen_server: «Erlybank»

Автор оригинала: Mitchell Hashimoto
  • Перевод
Предыстория
Введение в Open Telecom Platform/Открытую Телекомуникационную Платформу(OTP/ОТП)

Это первая статья из цикла статей, описывающих все концепции, которые относятся к Erlang/OTP. Чтобы вы могли найти все эти статьи в дальнейшем, они маркируются специальным тегом: Otp introduction(здесь я сделал ссылку на теги хабра). Как обещано во введении в OTP, мы будем создавать сервер, обслуживающий фейковые банковские аккаунты людей в «Erlybank» (да, я люблю глупые имена).

Сценарий: ErlyBank начинает свою деятельность, и руководителям необходимо встать с правильной ноги, создав масштабируемую систему для управления банковскими аккаунтами их важной базы покупателей. Услышав про мощь Erlang, они наняли нас сделать это! Но чтобы посмотреть, на что мы годны, они сначала хотят увидеть простенький сервер, умеющий создавать и удалять аккаунты, делать депозит и изъятие денег. Заказчики хотят только прототип, а не что-то, что они смогут запустить в производство.

Цель: мы создадим простой сервер и клиент, используя gen_server. Так как это просто прототип, аккаунты будут храниться в памяти и идентифицироваться по имени. Никакой другой информации для создания аккаунта будет не нужно. И конечно же, мы сделаем проверку для операций депозита и изъятия денег.

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

Если вы готовы, жмите «Читать дальше», чтобы начать! (Если вы уже не читаете всю статью целиком:) )


Что находится в gen_server?


gen_server — это интерфейсный модуль для реализации клиент-серверной архитектуры. Когда вы используете этот OTP модуль, множество вкусностей достается вам «for free», но об этом я расскажу позже. Также, позже в серии, я поговорю о супервизорах и сообщениях об ошибках. А этот модуль практически не претерпит изменений.

Так как gen_server — это интерфейс, вам необходимо реализовать некоторое количество его методов или функций возврата(callbacks англ.):
  • init/1 — Инициализация сервера.
  • handle_call/3 — Обрабатывает call-запрос. Клиент, пославший ему call-запрос, блокируется до тех пор, пока не получит ответ.
  • handle_cast/2 — Обрабатывает cast-запрос. cast-запрос идентичен call-запросу за исключением того, что он асинхронный; клиент продолжает работу во время cast-запроса.
  • handle_info/2 — В своем роде «catch all» метод. Если серверу приходит сообщение, и это не call и не cast, то оно придет сюда. Примером такого сообщения может служить EXIT process сообщение, если ваш сервер соединен(залинкован) с другим процессом.
  • terminate/2 — Вызывается, когда сервер останавливается. Здесь можно сделать любые необходмые перед выходом операции.
  • code_change/3 — Вызывается, когда сервер обновляется в реальном времени. Вы должны поместить заглушку в этот метод. Этот метод будет рассмотрен в деталях в будущих статьях.

Скелет сервера


Я всегда начинаю писать с некоторого обобщенного скелета. Можете посмотреть его здесь.

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

Как вы видите, модуль называется eb_server. Он имплементирует все callback-методы, которые я указал выше и также добавляет еще один: start_link/0, который будет использоваться для старта сервера. Я вставил порцию этого кода ниже:

start_link() ->
  gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).

Здесь вызывается start_link/4 метод модуля gen_server, который стартует сервер и регистрирует процесс с помощью атома, определенного макросом SERVER, который по умолчанию является всего лишь именем модуля. Оставшиеся аргументы — это модуль gen_server, в данном случае он сам, и любые другие аргументы, а в конце опции. Мы оставляем аргументы пустыми(они нам не нужны) и мы не указываем никаких опций. За более подробным описанием этого метода обращайтесь на страницу gen_server manual.

Инициализация Erlybank сервера


Сервер стартуется методом gen_server:start_link, который вызывает метод init нашего сервера. В нем вам следует инициализировать состояние(данные) сервера. Состояние(данные) может быть чем угодно: атомом, списком значений, функцией, чем угодно! Это состояние(данные) передается серверу в каждом callback-методе. В нашем случае мы бы хотели хранить список всех аккаунтов и значений их баланса. Дополнительно, мы бы хотели искать аккаунты по именам. Для этого я собираюсь использовать модуль dict, который сохраняет пары «ключ-значение» в памяти.

Примечание для объекто-ориентированных умов: если вы пришли из ООП, вы можете принять состояние сервера за переменные его экземпляра. В каждом callback методе вам будут доступны эти переменные, и также вы сможете их изменять.

Итак, окончательный вид метода init:

init(_Args) ->
  {ok, dict:new()}.

И правда, это просто! Так, для Erlybank сервера одно из ожидаемых значений, которое возвращает init, — это {ok, State}. Я просто возвращаю ok и пустой ассоциативный массив как состояние(данные). И мы не передаем аргументы в init(которые все равно пустой массив, помните, из start_link), так что я предваряю аргумент символом "_", чтобы указать на это.

Call или Cast? Вот вопрос


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

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

Cast — это неблокирующий или асинхронный метод. Это означает, что когда клиент посылает сообщение серверу, он продолжает работать дальше, не дожидаясь ответа сервера. Сейчас Erlang гарантирует, что все посланные процессам сообщения дойдут до адресатов, так что, если вы явно не нуждаетесь в ответе сервера, вам следует использовать cast, — это позволит вашему клиенту работать дальше. То есть, вам не нужно делать call только для того, чтобы удостовериться, что ваше сообщение дошло — оставьте это Erlang.

Создание банковского аккаунта


Сначала начало:), Erlybank нужен способ создания новых аккаунтов. Быстро, проверьте себя: если бы вам нужно было создать банковский аккаунт, что бы вы использовали: cast или call? Думайте качественно… Какое значение должно быть возвращено? Если вы выбрали call — вы правы, хотя ничего страшного, если нет. Вы должны быть уверенными, что аккаунт создан успешно, вместо того, чтобы просто полагаться на это. В нашем случае я собираюсь выполнить его через cast, так как проверку ошибок мы сейчас не производим.

Первое, я создам API метод, который будет вызываться извне модуля, чтобы создать аккаунт:

%%--------------------------------------------------------------------
%% Function: create_account(Name) -> ok
%% Description: Creates a bank account for the person with name Name
%%--------------------------------------------------------------------
create_account(Name) ->
  gen_server:cast(?SERVER, {create, Name}).

Он посылает cast-запрос серверу, который мы зарегистрировали как ?SERVER в start_link. Запрос представляет из себя тапл {create, Name}. В случае использования cast немедленно возвращается «ok», что также возвращается нашей функцией.

Сейчас нам надо написать callback-метод для сервера, который будет обрабатывать этот cast:

handle_cast({create, Name}, State) ->
  {noreply, dict:store(Name, 0, State)};
handle_cast(_Msg, State) ->
  {noreply, State}.

Как вы можете видеть, мы только добавили еще одно определение для handle_cast чтобы обработать еще один запрос. Затем мы сохраняем его в массиве со значением 0, отображающим текущий баланс аккаунта. handle_cast возвращает {noreply, State}, где State — это новое состояние(данные) сервера. Итак, в этот раз мы возвращаем новый массив с добавленным аккаунтом.

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

Содержимое файла eb_server.erl на данный момент вы можете посмотреть здесь.

Денежный депозит


Мы обещали нашему клиенту, Erlybank, что мы добавим API для депозита денег и базовую проверку. Так что нам надо написать deposit API метод так, чтобы сервер был обязан проверять аккаунт на существование перед внесением денег на счет. И снова, проверьте себя: cast или call? Ответ прост: call. Мы должны быть уверены, что деньги дошли и уведомить пользователя.

Как и раньше, я пишу сначала API метод:

%%--------------------------------------------------------------------
%% Function: deposit(Name, Amount) -> {ok, Balance} | {error, Reason}
%% Description: Deposits Amount into Name's account. Returns the
%% balance if successful, otherwise returns an error and reason.
%%--------------------------------------------------------------------
deposit(Name, Amount) ->
  gen_server:call(?SERVER, {deposit, Name, Amount}).

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

handle_call({deposit, Name, Amount}, _From, State) ->
  case dict:find(Name, State) of
    {ok, Value} ->
      NewBalance = Value + Amount,
      Response = {ok, NewBalance},
      NewState = dict:store(Name, NewBalance, State),
      {reply, Response, NewState};
    error ->
      {reply, {error, account_does_not_exist}, State}
  end;
handle_call(_Request, _From, State) ->
  Reply = ok,
  {reply, Reply, State}.

Вау! Много нового и непонятного! Определение метода выглядит похожим на handle_cast, исключая новый аргумент From, который мы не используем. Это идентификатор pid вызывающего процесса, так что мы можем послать ему дополнительное сообщение, если понадобится.

Мы обещали Erlybank, что сделаем проверку существования аккаунта, и мы делаем это в первой строчке кода. Мы пытаемся найти значение из массива состояния(данных) эквивалентное пользователю, пытающемуся сделать депозит. Метод find модуля dict возвращает одно из 2-х значений: или {ok, Value}, или error.

В случае, если аккаунт существует, Value равняется текущему балансу аккаунта, — добавляем сумму депозита к нему. Затем мы сохраняем новый баланс аккаунта в массиве и присваиваем его переменной. Я также сохраняю ответ сервера в переменной, чтио выглядит просто как комментарий к deposit API, говорящий: всё {ok, Balance}. Потом, возвращая {reply, Reply, State}, сервер посылает обратно Reply и сохраняет новое состояние(данные).

С другой стороны, если аккаунт не существует, мы не изменяем состояние(данные) вовсе, а в ответ посылаем тапл {error, account_does_not_exist}, который опять же следует спецификации в комментариях deposit API.

Снова, здесь обновленная версия eb_server.erl.

Удаление аккаунта и снятие денег


Сейчас я собираюсь оставить как упражнение читателю написание API для удаления аккаунта и снятия денег с аккаунта. У вас есть все необходимые знания для того, чтобы сделать это. Если вам нужна помощь с модулем dict, обращайтесь к dict API reference. При снятии денег со счета проверяйте аккаунт как на существование, так и на наличие необходимой для снятия суммы денег. Вам не нужно обрабатывать отрицательные значения.

Когда вы закончите, или если вы не закончите(надеюсь, нет!), можете найти ответы здесь.

Заключительные примечания


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

Если вы хотите узнать больше о callback-методах, возвращаемых значениях и других, более продвинутых вещах gen_server, читайте документацию по gen_server. Прочитайте ее всю, правда.

Также, я знаю, что не касался the метода code_change/3, который по каким-то причинам является самым вкусным для людей. Не волнуйтесь, у меня уже есть наброски для статьи(ближе к концу серии), посвященной проведению апгрейдов на работающей системе(горячая замена кода), и вот там этот метод будет играть одну из главных ролей.

Следующая статья будет через несклько дней. Она будет посвящена gen_fsm. Так что, если эта статья пощекотала вам мозг, «почувствуйте свободу прыгнуть в мануал»:) и действуйте сами. Может быть, вам удастся угадать продолжение истории ErlyBank, которое я буду делать с gen_fsm.;)
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 28

    +1
    Очень интересно — пошел пробовать :)
    Но как быть при обновлении счет с «условиями гонки» (race condition)? Если, например, придет сразу 2 перевода? Корректно ли обновится счет?
      0
      Насколько я понял — это статья начального ознакомительного уровня. Поэтому здесь все упрощено и такие вопросы не учитываются:)
        0
        я вот тоже пока не понимаю, является ли handle_call «атомарным». перерыл много статей, везде хэлло вордлы, надо видимо писать тесты самому…

        а вцелом, сам армстронг, например, предлагает использовать т.н. транзакционнную память armstrongonsoftware.blogspot.com/2006/09/pure-and-simple-transaction-memories.html
        но у нее тоже есть свои нехилые заморочки
        и пишут о ней весьма разное mags.acm.org/queue/200809/?pg=49
          0
          Читал про транзакционную память. В первую очередь меня смутило, что надо будет «руками» отслеживать транзакции — шикарный источник трудноуловимых ошибок. Непонятно, как менять сразу несколько переменных — так чтобы транзакция была целостной. Может быть я избаловался ACID-базами, но иначе какие это транзакции? Тем более раз говорим про банковскую систему, то там обязательно надо делать транзакции: допустим у вас перевод с одного счета на другой — как ее будете контролировать, чтобы человек не ушел в минус или сумма счета была правильной?
            0
            по старинке, локами ))
            да не, я и говорю, вот и не ясно…

            эрланг ведь, как и любой другой язык, не решает эти вопросы, он просто предлагает определенные удобства и формализации. все то же самое можно делать и на stackless python, и на jocaml, и на чем угодно, но стаклесс — костыль, а эрланг приспособлен by design.
            а проблема изменения глобальных state'ов пока остается на разработчике в любом случае
              0
              Я согласен, что эрланг более подходящ для таких задач.
              Но одновременно вылезает масса других проблем в более простых местах. И для их разруливания требуются усилия.
              По идее глобальных переменных быть не должно — так уж устроен Э. Значит должен быть другой вариант решения этой задачи, более подходящий для этой ситуации. Если его нет, то это проблема дизайна языка, и ее конечно можно решать с помощью разных модулей, но это будут жуткие костыли.
                0
                ну почему масса, я вижу реальную проблему только с этим.

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

                ну будут те же локи, зато остальное удобно масштабировать и т.д.
                эрланг не лучший язык, но зато можно решать актуальные проблемы без костылей и достаточно стабильно, за нативные микротреды можно многое простить )
            0
            Все вызовы gen_server выстраиваются в очередь и обрабатываются последовательно. То есть handle_call получается атомарный.
            0
            Любое общение между процессами осуществляется через очередь сообщений. handle_call/cast/info — просто очень высокая надстройка над обыкновенным оператором "!", они все точно так же последовательно выхватываются из очереди. «Сразу два» просто-напросто не бывает :)
            Другое дело, если проверка и изменение разнесены на два отдельных обращения к процессу. Тогда, конечно, кто-нибудь может вклиниться; но для этого надо соответствующим образом планировать интерфейс модуля.
              0
              Все вызовы gen_server выстраиваются в очередь и обрабатываются последовательно. Поэтому гонок быть не должно, сколько не вызывай параллельно gen_server.
              Возможно я неправильно вас понял, тогда поправьте :)
              0
              Спасибо за наводку на модуль dict.
              Как он работает? Ведь его работа противоречит сразу 2 базовым принципам Erlang: отсутствие общей памяти и неизменяемость переменных.
                0
                Вот не знаю, надо читать документацию…
                  0
                  нет, все нормально )

                  D = dict:new(),
                  io:format(«D:~n~p~n», [D]),
                  D1 = dict:store(«foo», bar, D),
                  io:format(«D:~n~p~n», [D]),
                  io:format(«D:~n~p~n», [D1]).
                    0
                    Мммм… не очень понял, что вы ожидали и что получили :)
                    Это не «подкол», это вопрос — я еще совсем мало понимаю в Эрланге :)

                    Попробовал в консоли:
                    Eshell V5.6.3 (abort with ^G)
                    (erl@laptop)1> D=dict:new().
                    {dict,0,16,16,8,80,48,
                    {[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]},
                    {{[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]}}}

                    (erl@laptop)2> D.
                    {dict,0,16,16,8,80,48,
                    {[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]},
                    {{[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]}}}

                    (erl@laptop)3> dict:store('test', 123, D).
                    {dict,1,16,16,8,80,48,
                    {[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]},
                    {{[],[],[],[],
                    [[test|123]],
                    [],[],[],[],[],[],[],[],[],[],[]}}}

                    (erl@laptop)4> D.
                    {dict,0,16,16,8,80,48,
                    {[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]},
                    {{[],[],[],[],[],[],[],[],[],[],[],[],[],[],[],[]}}}


                    После такой процедуры сам D не изменился:
                    (erl@laptop)6> dict:fetch('test', D).
                    ** exception error: no function clause matching dict:fetch_val(test,[])


                    Тогда как быть с конкуренцией? И получается, dict — это не хранилище, а просто переменная с гибкой структурой, такая же как и все остальные в Эрланге. А значит, у каждого процесса будет свой реестр банковских счетов. Их действительно можно использовать асинхронно. Но теперь с асинхронностью стало все еще непонятнее. Получается, что каждый счет будет иметь массу значений — и одно из них истинное? Или я совсем запутался?
                      0
                      ой, не туда запостил, см ниже )
                    0
                    Эти принципы прекрасно соблюдаются. Все модули, работающие со сложными структурами типа словарей и деревьев, не изменяют структуру, которую им дают на входе, а разбирают ее на куски и строят и возвращают совершенно новую.
                    0
                    ожидал, что не изменится, и он не изменился )
                    >dict — это не хранилище, а просто переменная с гибкой структурой, такая же как и все остальные в Эрланге
                    ага

                    >у каждого процесса будет свой реестр банковских счетов
                    это уже зависит от архитектуры приложения. мы же не будем гонять по процессам весь реестр, он большой, храниться он будет в бд…
                    конечно, нельзя допустить чтобы у процессов были разные конфликтующие данные…
                      0
                      > это уже зависит от архитектуры приложения. мы же не будем гонять по процессам весь реестр, он
                      > большой, храниться он будет в бд…
                      > конечно, нельзя допустить чтобы у процессов были разные конфликтующие данные…
                      Конечно, не хотелось бы гонять весь реестр по всем процессам, да еще бегать согласовывать эти данные. Как вариант решения — транзакционная память, но и она не без проблем.

                      Мне сейчас видится такая модель: Есть процесс отвечающий за список счетов — он ведет этот dict. В каждом дикте одним из элементов является ссылка на процесс, который отвечает за один конкретный аккаунт. Этот процесс может выглядеть так:

                      cnt(Name, Count) ->
                          SName = Name,
                          SCount = Count,
                          io:format("Before rec. Name: ~p, Count: ~p", [SName, SCount]),
                          receive 
                              {init, N, C} ->
                                  cnt(N, C);
                              {inc, C} ->
                                  cnt(SName, SCount - C);
                              status ->
                                  io:format("Name: ~p, Count: ~p~n", [SName, SCount]),
                                  cnt(SName, SCount);
                              stop ->
                                  io:format("Proc stop", []);
                              _ ->
                                  io:format("Default msg", []),
                                  cnt(SName, SCount)
                          end.
                      

                      С ним можно работать стандартно:
                      1>S1 = spawn(module, cnt, ["", 0]).

                      2> S1! {init, «Сид», 100}.
                      Before rec. Name: «Сид», Count: 100{init,«Сид»,100}

                      4> S1! {inc, 20}.
                      Before rec. Name: «Сид», Count: 80{inc,20}

                      5> S1! status.
                      Name: «Сид», Count: 80


                      Но по идее, это все должно делаться не на чистом Эрланге, а на OTP. Но как?
                      Например, перечисление денег надо сделать синхронным и хорошо бы транзакционным.
                        0
                        черт опять не туда запостил )
                          0
                          и, кстати, acid все же есть ) en.wikipedia.org/wiki/Mnesia#Transactions
                        0
                        тут получается узкое место — один процесс, хранящий все аккаунты. мне кажется, лучше делать просто рассылку запроса процессам, отвечающим за аккаунты, и ответит нужный процесс. т.е. мы можем разбить список аккаунтов на несколько процессов (на кластер например), которые будут уже контролировать сами процессы-аккаунты.
                        как-то так
                          0
                          Само собой за каждый аккаунт отвечает свой процесс.
                          Но как их искать в памяти? Я не придумал пока ничего лучше некоторого процесса-справочника, который будет хранить список всех аккаунтов. Но при этом он станет узким местом. Если его использовать только как справочник один раз.
                          То есть, если кто-то хочет работать с каким-то счетом, то он должен обратиться к этому главному процессу и получить у него ссылку на процесс-акканут. После этого, уже можно будет работать непосредственно с аккаунтом, уже без обращений к главному. Причем здесь же возможен вариант, когда процесс-счет не существует вообще либо не загружен из БД. Тогда главный просто загружает процесс-акканут и возвращает его. Для работающего со счетом все прозрачно.

                          Не очень понял вашу мысль
                          > просто рассылку запроса процессам, отвечающим за аккаунты, и ответит нужный процесс.
                          То есть опросить все процессы? Не будет ли это лишним оверхедом? Может Эрланг содержит какие-то нативные средства для таких операций? Послать сигнал процессам, удовлетворяющим определенной маске. Но все равно пока не ясно, как хранить такой список? Даже если в виде списка. (Кстати, у меня какое-то предубеждение в жуткой неэффективности такой схемы работы со списками).
                            0
                            >То есть опросить все процессы?
                            нет, опросить «справочник», но мы просто разделяем его на несколько частей-процессов, которые и опрашиваем, и он перестает быть узким.

                            т.е. например: есть два процесса, в каждом по половине справочника. посылаем им запросы, они *параллельно* проверяют у себя аккаунт, и дальше уже по-разному можно. отвечает нашедший справочник или уже сам процесс-аккаунт и т.д.
                              0
                              Т.е. справочник все равно становиться неким state-var нашего вычислительного процесса? Если так, то получаем объектный подход, вместо обещанного функционального, где понятия состояния не существует впринципе.

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

                              Объясните пожалуйста, уважаемые гуру функционального языка ерланг, как это все реализовываеться на практике (я пока только начал разбираться в ФП).
                                0
                                ну я, мягко говоря, не гуру эрланга и фп, но могу сказать, что чистое stateless фп (как и любая парадигма, религия и т.д.) идеально применимо к решению сферических задач в вакууме, а не реальных-полезных на практике, такие дела.
                                как обычно, нужно комбинировать…

                                дело в том, что эрланг дает нам out-of-the-box удобности типа легких потоков, эффективного ipc, распределение между ярдами, компами, динамика среды, упрощение построения протоколов, слежение за процессами и т.д., все остальное лежит на девелопере, от этого никуда не деться )
                                магическое избавление от стейта это такой хитрый маркетинг, ну типа как ооп.

                                никакая парадигма не избавляет от того, что нужно как-то решать конкурентный доступ к данным не теряя сильно в производительности и предсказуемости.
                          0
                          тапл — это что? Знаю тьюпл, который кортеж, а про тапл впервые слышу.
                            0
                            Давайте пожалуйста ссылку на оригинальную статью. Перевод иногда сложно понять, но всеравно спасибо!
                              0
                              Ссылка всегда есть в конце статьи. Жми на «Mitchell Hashimoto»

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое