- PVSM.RU - https://www.pvsm.ru -
Статья опубликована [1] 2 августа 2018 года
Это вторая статья про командную строку Windows, где мы обсудим новую инфраструктуру и программные интерфейсы псевдоконсоли Windows, то есть Windows Pseudo Console (ConPTY): зачем мы её разработали, для чего она нужна, как работает, как её использовать и многое другое.
В прошлой статье «Тяжкое наследие прошлого. Проблемы командной строки Windows» [2] мы рассказали о предпосылках появления терминала и эволюции командной строки в Windows, а также начали изучать внутреннее устройство Windows Console и инфраструктуры Windows Command-Line. Мы также обсудили многие преимущества и главные недостатки консоли Windows.
Один из недостатков заключается в том, что Windows пытается быть «полезной», но мешает разработчикам альтернативных и сторонних консолей, разработчикам служб и т.д. При создании консоли или службы разработчикам нужно иметь доступ к каналам связи, по которым их терминал/служба обменивается данными с приложениями командной строки, или предоставлять доступ к ним. В мире *NIX это не проблема, потому что *NIX предоставляет инфраструктуру «псевдотерминала» (PTY), которая позволяет легко создавать коммуникационные каналы для консоли или службы. Но в Windows такого не было…
… до настоящего времени!
Прежде чем подробно рассказать о нашей разработке, давайте кратко вернёмся к развитию терминалов.
Как обсуждалось в прошлой статье [2], в первые дни вычислений пользователи управляли компьютерами с помощью электромеханических телетайпов (TTY), подключённых к компьютеру через какой-то последовательный канал связи (обычно через токовую петлю 20 мА [3]).
Кен Томпсон и Деннис Ричи (стоя) работают на DEC PDP-11 по телетайпу (сообщения без электронного дисплея)
На смену телетайпам пришли компьютеризированные терминалы с электронными дисплеями (обычно ЭЛТ-экранами). Как правило, терминалы — очень простые устройства (отсюда термин «тупой терминал»), содержащие только электронику и вычислительную мощность, необходимую для следующих задач:
Несмотря на простоту (а может, благодаря ей), терминалы быстро стали основным средством для управления миникомпьютерами, мейнфреймами и серверами: большинство операторов ввода данных, компьютерных операторов, системных администраторов, учёных, исследователей, разработчиков программного обеспечения и светил индустрии работали на терминалах DEC, IBM, Wyse и многих других.
Адмирал Грейс Хоппер в своём офисе с терминалом DEC VT220 на столе
Начиная с середины 1980-х годов вместо специализированных терминалов постепенно начали применяться компьютеры общего назначения, которые становились более доступными, популярными и мощными. Во многих ранних ПК и других компьютерах 80-х годов имелись терминальные приложения, которые открывали соединение по порту RS-232 на ПК и обменивались данными с кем угодно на другом конце соединения.
По мере того как компьютеры общего назначения становились всё более изощрёнными, появился графический пользовательский интерфейс (GUI) и целый новый мир одновременно работающих приложений, включая терминальные приложения.
Но возникла проблема: как терминальному приложению взаимодействовать с другим приложением командной строки, запущенным на той же машине? И как физически подключить последовательный кабель между двумя приложениями, работающими на одном компьютере?
В мире *NIX проблему решили введением псевдотерминала (PTY) [5].
PTY эмулирует серийное телекоммуникационное оборудование в компьютере, выставляя ведущее и ведомое псевдоустройства (“master” и “slave”): терминальные приложения подключаются к ведущему псевдоустройству, а приложения командной строки (например, оболочки вроде cmd, PowerShell и bash) — к ведомому псевдоустройству. Когда терминальный клиент передаёт текст и/или команды управления (закодированные как текст) ведущему псевдоустройству, текст транслируется на связанное с ним ведомое. Текст от приложения направляется на ведомое псевдоустройство, затем обратно на ведущее и, таким образом, на терминал. Данные всегда отправляются/принимаются асинхронно.
Приложение/оболочка псевдотерминала
Важно отметить, что «ведомое» псевдоустройство эмулирует поведение физического терминала и преобразует командные символы в сигналы POSIX. Например, если пользователь вводит в терминал CTRL+C [6], то значение ASCII для CTRL+C (0x03) отправляется через ведущее устройство. При получении на ведомом псевдоустройстве значение 0x03 удаляется из входного потока и генерируется сигнал SIGINT [7].
Такая инфраструктура PTY широко используется терминальными приложениями *NIX, менеджерами текстовых панелей (например, screen, tmux) и т.д. Данные приложения вызывают openpty()
, который возвращает пару файловых дескрипторов (fd) для ведущего и ведомого устройств PTY. Затем приложение может форкнуть/выполнить дочернее приложение командной строки (например, bash), которое использует свои ведомые fd для прослушивания и возврата текста на подключённый терминал.
Этот механизм позволяет терминальным приложениям «разговаривать» непосредственно с приложениями командной строки, запущенными локально, как терминал разговаривал бы с удалённым компьютером через последовательное/сетевое соединение.
Как мы обсуждали в предыдущей статье, в то время как консоль Windows концептуально похожа на традиционный терминал *NIX, она отличается несколькими ключевыми способами, особенно на самых низких уровнях, которые могут вызвать проблемы у разработчиков приложений командной строки Windows, сторонних терминалов/консолей и серверных приложений:
Многие, многие разработчики часто просили PTY-подобный механизм под Windows, особенно те, кто работает с инструментами ConEmu/Cmder, Console2/ConsoleZ, Hyper, VSCode, Visual Studio, WSL, Docker и OpenSSH.
Даже Питер Брайт — технологический редактор Ars Technica — попросил внедрить механизм PTY через несколько дней, как я начал работать в команде Console:
И недавно ещё раз:
Что ж, мы наконец-то сделали это: мы создали псевдоконсоль для Windows:
С момента образования Console Team около четырёх лет назад группа занималась капитальным ремонтом консоли Windows и внутренних механизмов работы командной строки. При этом мы регулярно и тщательно рассматривали описанные выше вопросы и многие другие связанные вопросы и проблемы. Но инфраструктура и код были не готовы, чтобы сделать возможным выпуск псевдоконсоли… до настоящего момента!
Новая инфраструктура псевдоконсоли Windows (ConPTY), API и некоторые другие соответствующие изменения устранят/облегчат целый класс проблем… не ломая обратную совместимость с существующими приложениями командной строки!
Новые Win32 ConPTY API (официальная документация будет скоро опубликована) теперь доступны в последних инсайдерских сборках Windows 10 и соответствующих Windows 10 Insider Preview SDK [8]. Они появятся в следующем крупном релизе Windows 10 (где-то осенью/зимой 2018).
Чтобы понять ConPTY, нужно изучить архитектуру консоли Windows, а точнее… ConHost!
Важно понимать, что хотя ConHost реализует всё, что вы видите и знаете как приложение Windows Console, но ConHost также содержит и реализует большую часть инфраструктуры командной строки Windows! Отныне же ConHost становится настоящим «консольным узлом», поддерживая все приложения командной строки и/или GUI-приложения, которые взаимодействуют с приложениями командной строки!
Как? Почему? Что? Давайте разберёмся подробнее.
Вот высокоуровневое представление внутренней архитектуры консоли/ConHost:
В сравнении с архитектурой из предыдущей статьи [2], ConHost теперь содержит несколько дополнительных модулей для обработки VT и новый модуль ConPTY, реализующий открытые API:
INPUT_RECORD
и сохраняет во входном буфере. Он также обрабатывает управляющие последовательности, такие как 0x03 (CTRL+C), преобразуя их в KEY_EVENT_RECORDS
, которые производят соответствующее управляющее действие.Хорошо, но что это на самом деле значит?
Чтобы лучше понять влияние новой инфраструктуры ConPTY, давайте рассмотрим, как до сих пор работали консольные приложения Windows и приложения командной строки.
Всякий раз, когда пользователь запускает приложение командной строки, такое как Cmd, PowerShell или ssh, Windows создаёт новый процесс Win32, в который загружает исполняемый двоичный файл приложения и любые зависимости (ресурсы или библиотеки).
Вновь созданный процесс обычно наследует дескрипторы stdin и stdout от своего родителя. Если родительский процесс был процессом Windows GUI, то дескрипторы stdin и stdout отсутствуют, поэтому Windows развернёт и присоединит новое приложение к новому экземпляру консоли. Связь между приложениями командной строки и их консолью передается через ConDrv.
Например, при запуске из экземпляра PowerShell без повышенных прав новый процесс приложения унаследует родительские дескрипторы stdin/stdout и, следовательно, получит входные данные и выдаст выходные данные в ту же консоль, что и родитель.
Здесь нужно немного оговориться, потому что в некоторых случаях приложения командной строки запускаются прикреплёнными к новому экземпляру консоли, особенно по соображениям безопасности, но обычно верно описание выше.
В конечном счёте, когда запускается приложение командной строки/оболочка, Windows подключает его к экземпляру консоли (ConHost.exe) через ConDrv:
Всякий раз, когда выполняется приложение командной строки, Windows подключает приложение к новому или существующему экземпляру ConHost. Приложение и его экземпляр консоли подключаются через драйвер консоли режима ядра (ConDrv), который отправляет/получает сообщения IOCTL, содержащие сериализованные запросы вызовов API и/или текстовые данные.
Исторически, как указано в предыдущей статье, работа ConHost сегодня относительно проста:
KEY_EVENT_RECORD
или MOUSE_EVENT_RECORD
и сохраняются во входном буфере.Когда приложение командной строки вызывает Windows Console API, то вызовы API сериализуются в сообщения IOCTL и отправляются через драйвер ConDrv. Он затем доставляет сообщения IOCTL на присоединённую консоль, которая декодирует и выполняет запрошенный вызов API. Возвращаемые/выходные значения сериализуются обратно в сообщение IOCTL и отправляются обратно в приложение через ConDrv.
Microsoft старается по возможности поддерживать обратную совместимость с существующими приложениями и инструментами. Особенно для командной строки. На самом деле 32-битные версии Windows 10 ещё могут запускать многие/большинство 16-битных приложений и исполняемых файлов Win16!
Как упоминалось выше, одна из ключевых ролей ConHost заключается в предоставлении сервисов своим приложениям командной строки, особенно устаревшим приложениям, которые вызывают и полагаются на консольный API Win32. Теперь ConHost предлагает и новые сервисы:
Ниже приведен пример, как современное консольное приложение общается с приложением командной строки через ConPTY ConHost.
В этой новой модели:
INPUT_RECORD
, которые отправляются в приложение командной строки
Последний момент важен! Когда старое приложение командной строки использует вызовы к Console API вроде WriteConsoleOutput(...)
, то указанный текст записывается в соответствующий выходной буфер ConHost. Периодически ConHost отображает изменённые области буфера вывода в виде текста/VT, который отправляется через stdout обратно в консоль.
В конечном счёте, даже традиционные приложения командной строки снаружи «говорят» текстом/VT без каких-либо изменений!
Используя новую инфраструктуру ConPTY, сторонние консоли теперь могут напрямую взаимодействовать с современными и традиционными приложениями командной строки и обмениваться со всеми ними данными в тексте/VT.
Описанный выше механизм отлично работает на одном компьютере, но также помогает при взаимодействии, например, с инстансом PowerShell на удалённом компьютере Windows или в контейнере.
При удалённом запуске приложения командной строки (т.е. на удалённых компьютерах, серверах или в контейнерах) имеется проблема. Дело в том, что приложения командной строки на удалённых машинах общаются с локальным инстансом ConHost, потому что сообщения IOCTL не предназначены для передачи по сети. Как же передать ввод из локальной консоли на удалённую машину и как получить выдачу из приложения, запущенного там? Более того, что делать с машинами Mac и Linux, где есть терминалы, но нет Windows-совместимых консолей?
Таким образом, чтобы удалённо управлять машиной Windows, нам нужен какой-то брокер связи, который сможет прозрачно сериализовать данные по сети, управлять временем жизни экземпляра приложения и т.д.
Возможно, что-то вроде ssh [9]?
К счастью, OpenSSH [10] недавно портировали на Windows [11] и добавили как дополнительную опцию Windows 10 [12]. PowerShell Core также использует ssh в качестве одного из поддерживаемых протоколов удалённого взаимодействия PowerShell Core Remoting [13]. А для тех, кто работал в Windows PowerShell, удалённое взаимодействие Windows PowerShell Remoting [14] по-прежнему остаётся приемлемым вариантом.
Давайте посмотрим, как сейчас OpenSSH для Windows [11] позволяет удалённо управлять оболочками и приложениями командной строки Windows:
В данный момент OpenSSH включает некоторые нежелательные усложнения:
Весело, правда? Вовсе нет! В такой ситуации многое может пойти наперекосяк, особенно в процессе имитации и отправки пользовательского ввода и очистки выходного буфера внеэкранной консоли. Это приводит к нестабильности, сбоям, повреждению данных, чрезмерному потреблению энергии и т.д. Кроме того, не все приложения выполняют работу по снятию не только самого текста, но и его свойств, из-за чего теряется форматирование и цвет!
Наверняка мы можем улучшить ситуацию? Да, конечно, можем — давайте сделаем несколько архитектурных изменений и применим наш новый ConPTY:
Из диаграммы видно, что схема изменилась следующим образом:
Такой подход с использованием ConPTY явно чище и проще для службы sshd. Вызовы Windows Console API выполняются полностью в экземпляре ConHost приложения командной строки, который преобразует все видимые изменения в текст/VT. Кто бы не подключался к ConHost, ему необязательно знать, что приложение там вызывает Console API, а не генерирует текст/VT!
Согласитесь, что этот новый механизм удалённого взаимодействия ConPTY приводит к элегантной, последовательной и простой архитектуре. В сочетании с мощными функциями, встроенными в ConHost, поддержкой старых приложений и отображением изменений от приложений, вызывающих консольные Console API, в виде текста/VT, новая инфраструктура ConHost и ConPTY помогает нам перенести прошлое в будущее.
ConPTY API доступен в текущей версии Windows 10 Insider Preview SDK [8].
К настоящему времени я уверен, что вам не терпится увидеть какой-то код ;)
Взглянем на декларации API:
// Creates a "Pseudo Console" (ConPTY).
HRESULT WINAPI CreatePseudoConsole(
_In_ COORD size, // ConPty Dimensions
_In_ HANDLE hInput, // ConPty Input
_In_ HANDLE hOutput, // ConPty Output
_In_ DWORD dwFlags, // ConPty Flags
_Out_ HPCON* phPC); // ConPty Reference
// Resizes the given ConPTY to the specified size, in characters.
HRESULT WINAPI ResizePseudoConsole(_In_ HPCON hPC, _In_ COORD size);
// Closes the ConPTY and all associated handles. Client applications attached
// to the ConPTY will also terminated.
VOID WINAPI ClosePseudoConsole(_In_ HPCON hPC);
Вышеприведённый ConPTY по API по сути выставляет для использования три новых функции:
CreatePseudoConsole(size, hInput, hOutput, dwFlags, phPC)
w
колонок и h
строк, используя каналы, созданные вызывающей стороной:
size
: ширина и высота (в символах) буфера ConPTYhInput
: для записи входных данных в PTY в виде последовательностей текст/VT в кодировке UTF-8hOutput
: для чтения выдачи из PTY в виде последовательностей текст/VT в кодировке UTF-8dwFlags
: Возможные значения:
PSEUDOCONSOLE_INHERIT_CURSOR
: созданный ConPTY попытается наследовать позицию курсора родительского приложения терминалаphPC
: дескриптор консоли для созданного ConPty
ResizePseudoConsole(hPC, size)
ClosePseudoConsole (hPC)
Ниже приведён небольшой пример кода вызова ConPTY API для создания псевдоконсоли и присоединения приложения командной строки к созданному ConPTY.
Примечание: полная реализация будет опубликована в нашем репозитории GitHub [15]
// Note: Most error checking removed for brevity.
// ...
// Initializes the specified startup info struct with the required properties and
// updates its thread attribute list with the specified ConPTY handle
HRESULT InitializeStartupInfoAttachedToConPTY(STARTUPINFOEX* siEx, HPCON hPC)
{
HRESULT hr = E_UNEXPECTED;
size_t size;
siEx->StartupInfo.cb = sizeof(STARTUPINFOEX);
// Create the appropriately sized thread attribute list
InitializeProcThreadAttributeList(NULL, 1, 0, &size);
std::unique_ptr<BYTE[]> attrList = std::make_unique<BYTE[]>(size);
// Set startup info's attribute list & initialize it
siEx->lpAttributeList = reinterpret_cast<PPROC_THREAD_ATTRIBUTE_LIST>(
attrList.get());
bool fSuccess = InitializeProcThreadAttributeList(
siEx->lpAttributeList, 1, 0, (PSIZE_T)&size);
if (fSuccess)
{
// Set thread attribute list's Pseudo Console to the specified ConPTY
fSuccess = UpdateProcThreadAttribute(
lpAttributeList,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
hPC,
sizeof(HPCON),
NULL,
NULL);
return fSuccess ? S_OK : HRESULT_FROM_WIN32(GetLastError());
}
else
{
hr = HRESULT_FROM_WIN32(GetLastError());
}
return hr;
}
// ...
HANDLE hOut, hIn;
HANDLE outPipeOurSide, inPipeOurSide;
HANDLE outPipePseudoConsoleSide, inPipePseudoConsoleSide;
HPCON hPC = 0;
// Create the in/out pipes:
CreatePipe(&inPipePseudoConsoleSide, &inPipeOurSide, NULL, 0);
CreatePipe(&outPipeOurSide, &outPipePseudoConsoleSide, NULL, 0);
// Create the Pseudo Console, using the pipes
CreatePseudoConsole(
{80, 32},
inPipePseudoConsoleSide,
outPipePseudoConsoleSide,
0,
&hPC);
// Prepare the StartupInfoEx structure attached to the ConPTY.
STARTUPINFOEX siEx{};
InitializeStartupInfoAttachedToConPTY(&siEx, hPC);
// Create the client application, using startup info containing ConPTY info
wchar_t* commandline = L"c:\windows\system32\cmd.exe";
PROCESS_INFORMATION piClient{};
fSuccess = CreateProcessW(
nullptr,
commandline,
nullptr,
nullptr,
TRUE,
EXTENDED_STARTUPINFO_PRESENT,
nullptr,
nullptr,
&siEx->StartupInfo,
&piClient);
// ...
Теперь cmd.exe подключён к экземпляру ConPTY, созданному CreatePseudoConsole()
. Вызывающий объект использует созданные им дескрипторы ConPTY для записи и чтения в/из экземпляра Cmd. Размер псевдоконсоли изменяется с помощью ResizePseudoConsole()
, а закрытие — по вызову ClosePseudoConsole()
.
Запись входных данных в ConPTY осуществляется просто:
// Input "echo Hello, World!", press enter to have cmd process the command,
// input an up arrow (to get the previous command), and enter again to execute.
std::string helloWorld = "echo Hello, World!nx1b[An";
DWORD dwWritten;
WriteFile(hIn, helloWorld.c_str(), (DWORD)helloWorld.length(), &dwWritten, nullptr);
Следующий сценарий показывает, как изменить размер ConPTY:
// Suppose some other async callback triggered us to resize.
// This call will update the Terminal with the size we received.
HRESULT hr = ResizePseudoConsole(hPC, {120, 30});
Нет ничего проще закрытия ConPTY:
ClosePseudoConsole(hPC);
Примечание: закрытие ConPTY завершит связанный ConHost и любые присоединённые клиенты.
Внедрение ConPTY API — пожалуй, одно из самых фундаментальных и раскрепощающих изменений, произошедших с командной строкой Windows за последние годы… если не десятилетия!
Мы уже портировали на ConPTY API некоторые инструменты Microsoft, а сейчас сотрудничаем с несколькими командами внутри Microsoft (подсистема Windows для Linux (WSL), команды Windows Containers, VSCode, Visual Studio и др.), а также с некоторыми независимыми разработчиками, включая @ConEmuMaximus5 [16] — создателя потрясающей консоли ConEmu [17] для Windows.
Но нам нужна ваша помощь, чтобы распространить информацию и начать использовать новые ConPTY API.
Если у вас традиционное приложение командной строки, то вы свободны и можете ничего не делать: ConHost сделает всю работу за вас. Программа может продолжать работать как раньше и полагаться на вызовы Console API. Приложение продолжит работать как обычно, в то же время получив дополнительный бонус в виде улучшенного, более качественного удалённого взаимодействия.
Но если хотите, можно постепенно ввести новую поддержку VT, например, для новых функций — решать вам.
С другой стороны, если вы сейчас планируете новые приложения командной строки Windows, то мы настоятельно рекомендуем транслировать текст/VT в кодировке UTF-8 вместо обращения к Console API: такой «разговор на VT» даст доступ ко многим функциям, которые не будут доступны через Console API (например, поддержка 16M RGB True Color [18]).
Если вы работаете над автономным приложением консоли/терминала или интегрируете консоль в приложение, то мы настоятельно призываем вас как можно скорее изучить и принять новые ConPTY API: использование новых программных интерфейсов вместо старого механизма внеэкранной консоли, скорее всего, ликвидирует несколько классов ошибок, одновременно повысив стабильность, надёжность и производительность.
В качестве примера команда VSCode в настоящее время решает вопрос (GitHub #45693 [19]) с несколькими проблемами, вызванными нынешним отсутствием псевдоконсоли Windows.
Новые ConPTY API станут доступны в релизе Windows 10 осенью/зимой 2018 года.
Для поддержки более ранних версий Windows, вероятно, потребуется в рантайме проверять, поддерживает ли текущая версия ConPTY. Как и с большинством Win32 API, эффективным способом проверки наличия API является использование метода Runtime Dynamic Linking путём вызова LoadLibrary()
и GetProcAddress()
.
Если текущая версия Windows поддерживает ConPTY, ваше приложение сможет найти и вызвать новые API ConPTY. Если нет, придётся вернуться к запутанным механизмам, используемым до сих пор.
Ещё одна длинная статья… это становится привычкой! Ещё раз, если вы смогли дочитать до этого места, СПАСИБО! :D
Из приведённой информации можно сделать многие выводы, но важно подчеркнуть, почему мы вносим такие улучшения, а также суть реализованных изменений. Наша цель — устранить целый класс проблем и ограничений для разработчиков консольных и серверных приложений, а также сделать разработку кода для инфраструктуры командной строки Windows более мощной, последовательной и увлекательной.
Будем рады отзывам через хаб обратной связи [20]. О более сложных проблемах сообщайте в репозитории Windows Console на GitHub [15]. А если есть вопросы, стучитесь ко мне в твиттер [21].
Автор: m1rko
Источник [22]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/windows/289955
Ссылки в тексте:
[1] опубликована: https://blogs.msdn.microsoft.com/commandline/2018/08/02/windows-command-line-introducing-the-windows-pseudo-console-conpty/
[2] «Тяжкое наследие прошлого. Проблемы командной строки Windows»: https://habr.com/post/417679/
[3] токовую петлю 20 мА: https://en.wikipedia.org/wiki/Digital_current_loop_interface
[4] RS-232: https://en.wikipedia.org/wiki/RS-232
[5] псевдотерминала (PTY): https://en.wikipedia.org/wiki/Pseudoterminal
[6] CTRL+C: https://en.wikipedia.org/wiki/Control-C
[7] сигнал SIGINT: https://en.wikipedia.org/wiki/Signal_(IPC)#SIGINT
[8] Windows 10 Insider Preview SDK: https://www.microsoft.com/en-us/software-download/windowsinsiderpreviewSDK
[9] ssh: https://en.wikipedia.org/wiki/Secure_Shell
[10] OpenSSH: https://www.openssh.com/
[11] портировали на Windows: https://github.com/PowerShell/Win32-OpenSSH
[12] дополнительную опцию Windows 10: https://blogs.msdn.microsoft.com/commandline/2018/01/22/openssh-in-windows-10/
[13] PowerShell Core Remoting: https://docs.microsoft.com/en-us/powershell/scripting/core-powershell/running-remote-commands
[14] Windows PowerShell Remoting: https://docs.microsoft.com/en-us/powershell/scripting/core-powershell/running-remote-commands?view=powershell-6#windows-powershell-remoting
[15] нашем репозитории GitHub: https://github.com/microsoft/console
[16] @ConEmuMaximus5: https://twitter.com/ConEmuMaximus5
[17] ConEmu: https://conemu.github.io/
[18] поддержка 16M RGB True Color: https://blogs.msdn.microsoft.com/commandline/2016/09/22/24-bit-color-in-the-windows-console/
[19] GitHub #45693: https://github.com/Microsoft/vscode/issues/45693
[20] хаб обратной связи: https://insider.windows.com/en-us/how-to-feedback/
[21] стучитесь ко мне в твиттер: https://twitter.com/richturn_ms
[22] Источник: https://habr.com/post/420853/?utm_campaign=420853
Нажмите здесь для печати.