180 lines
52 KiB
HTML
<!doctype html>
| ||||
<meta charset="utf-8">
| ||||
<h1>Разрабатываем менеджер терминалов (Часть 1): Постановка задачи</h1>
| ||||
<div class="inner formatted-content">
| ||||
| ||||
<h3>Введение</h3> <p>Если мы хотим использовать один советник на своём торговом счёте, то никаких проблем — запускаем терминал, ставим и ждём результат. Представим себе ситуацию, когда торговля идёт хорошо, и возникает желание попробовать подключить ещё одного или нескольких советников. Если они могут работать не мешая друг другу, то хорошо. Если нет — надо запускать для них отдельные копии терминала. Также они понадобятся, если нужно будет торговать на разных счетах.</p> <p>Тут постепенно начинают возникать уже организационные и технические сложности. Как можно облегчить себе жизнь, обеспечив возможность удобного контроля за несколькими терминалами, на которых торгуют советники? Причём терминалы могут физически располагаться на разных компьютерах (серверах). Что для этого можно предпринять?</p> <p>Раньше для чего-то подобного можно было использовать MultiTerminal. Но он позволял осуществлять на нескольких счетах только ручную торговлю. И только на MetaTrader 4. Потом он и вовсе перестал поддерживаться, а его аналог для MetaTrader 5 так и не вышел. Существующие сейчас сторонние инструменты предлагают лишь мониторинг, но не полноценное управление.</p> <p></p> <p>Поэтому мы хотим сделать веб-интерфейс по управлению запуском торговых терминалов MetaTrader 5 на имеющихся компьютерах. Через разработанный локальный сайт можно будет видеть список запущенных экземпляров, смотреть более детальную информацию о работе каждого экземпляра, добавлять новые или удалять запущенные. Потенциально, этот список можно расширять довольно значительно. Это не только облегчит жизнь, но и повысит надежность всей торговой системы в целом.</p><br> <h3>Архитектура</h3> <p>Чтобы создать систему для управления распределёнными терминалами, нам нужна модульная и масштабируемая архитектура. Её можно разделить на три ключевых компонента, которые взаимодействуют друг с другом.</p> <p></p> <ol> <li><i>Главный веб-сервер</i>. Это ядро системы, которое предоставляет пользователю интерфейс и координирует работу. Он будет заниматься приёмом команд от пользователя (запуск, остановка, мониторинг), осуществлять отправку этих команд на соответствующие веб-серверы терминалов и выполнять агрегацию всей доступной информации для отображения в единой панели.<br><br></li> <li><i>Веб-сервер(ы) терм<D180>
| ||||
| ||||
<span class="keyword">return</span> <span class="string">'<h1>MT5 Manager</h1>'</span></pre> <p>Теперь осталось сделать последний, но очень важный шаг — добавить связующий декоратор. Но сначала, давайте немного подробнее поясним, что это такое.</p> <p>Декораторы в Python — это функции, которые модифицируют поведение других функций (или классов), не изменяя их код напрямую. Модификация выполняется за счёт создания новой функции, которая внутри себя как-то использует исходную функцию. Если эту новую функцию далее сохранить под именем исходной, то все дальнейшие вызовы будут адресованы уже к модифицированной функции. Таким образом можно добавлять дополнительную логику (например, проверку, логирование, кеширование) перед или после выполнением кода исходной основной функции. </p> <p>Чтобы применить декоратор к функции надо либо вызвать его напрямую:</p> <pre class="code"><span class="comment"># Исходная функция</span>
| ||||
| ||||
<span class="keyword">def</span> my_function():
| ||||
| ||||
<span class="keyword">pass</span>
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Вызываем функцию-декоратор, передавая ей исходную функцию</span>
| ||||
| ||||
<span class="comment"># Возвращаемую новую функцию запоминаем под тем же именем</span>
| ||||
| ||||
my_function = my_decorator(my_function)</pre> <p>Либо, что всегда и используется, просто указать имя нужного декоратора перед заголовком функции с символом @ вначале:</p> <pre class="code"><span class="comment"># Создание новой функции с применённым декоратором my_decorator</span>
| ||||
| ||||
@my_decorator
| ||||
| ||||
<span class="keyword">def</span> my_function():
| ||||
| ||||
<span class="keyword">pass</span></pre> <p>Добавим теперь нужный нам декоратор:</p> <pre class="code">@app.get(<span class="string">'/'</span>)
| ||||
| ||||
<span class="keyword">async</span> <span class="keyword">def</span> index():
| ||||
| ||||
<span class="keyword">return</span> <span class="string">'<h1>MT5 Manager</h1>'</span></pre> <p>То есть @app.get('/') — это декоратор, который указывает FastAPI, что функция <i>index()</i> должна обрабатывать GET-запросы по пути /.</p> <p>Соберём всё вместе в файле <i>main.py</i>:</p> <p></p> <pre class="code"><span class="comment"># Импортируем нужные классы из библиотек</span>
| ||||
| ||||
<span class="keyword">from</span> fastapi <span class="keyword">import</span> FastAPI
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Создаём объект приложения</span>
| ||||
| ||||
app = FastAPI()
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Обработчик GET-запросов по адресу / (корневой каталог)</span>
| ||||
| ||||
@app.get(<span class="string">'/'</span>)
| ||||
| ||||
<span class="keyword">async</span> <span class="keyword">def</span> index():
| ||||
| ||||
<span class="keyword">return</span> <span class="string">'<h1>MT5 Manager</h1>'</span></pre> <p>Минимальное веб-приложение готово к запуску. Теперь нам понадобится обратиться к серверу Uvicorn, запустив его в консоли с указанием нашего веб-приложения как источника правил обработки запросов:</p> <pre class="code">uvicorn main:app --reload --no-use-colors</pre> <p>Здесь <i>main:app</i> означает, что наше веб-приложение находится в файле с именем <i>main.py</i> в текущей папке, а объект приложения в исходном коде называется <i>app</i>. Параметр <i>--reload</i> заставляет автоматически перезапускать сервер при внесении изменений в исходный код веб-приложения. Параметр <i>--no-use-colors</i> отключает использование вывода текста в консоли с применением разных цветов (нужен если сервер запускается в консоли, которая это не поддерживает).</p> <p>После запуска мы увидим примерно следующее:</p> <p style="text-align:center;"> <img width="689" height="506" src="../images/1b770648-2366637735981.png" loading="lazy" alt="" style="vertical-align:middle;"></p> <p>По умолчанию веб-сервер будет использовать порт 8000, поэтому, чтобы увидеть результат его работы, зайдём в браузер по адресу http://127.0.0.1:8000. Вот что получится:</p> <p style="text-align:center;"> <img width="746" height="253" src="../images/9ac4a25c-4271273415225.png" loading="lazy" alt="" style="vertical-align:middle;"></p> <p>Как видно, на странице действительно показывается тот текст, который мы возвращаем из функции <i>index()</i>. Но только браузер почему-то не воспринимает HTML-теги заголовка, показывая их просто как часть текста страницы. Всё в порядке, так и должно быть. Дело в том, что по умолчанию сервер отдаёт результат, сопровождая его информацией, что это JSON-код. Поэтому браузер и не стал разбирать полученный текст как HTML-код. Сейчас мы это исправим.</p> <p><span style="background-color:rgb(255, 246, 200);">Импортируем</span> ещё один класс из фреймворка FastAPI, который является представлением ответа сервера в формате HTML. В строке применения декоратора добавим дополнительный параметр <i>response_class</i>, через который <span style="background-color:rgb(216, 232, 194);">передадим</span> имя класса <i>HTMLResponse</i>.</p> <pre class="code"><span class="comment"># Импортируем нужные классы из библиотек</span>
| ||||
| ||||
<span class="keyword">from</span> fastapi <span class="keyword">import</span> FastAPI
| ||||
| ||||
<span style="background-color:rgb(255, 246, 200);"><span class="keyword">from</span> fastapi.responses <span class="keyword">import</span> HTMLResponse</span>
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Создаём объект приложения</span>
| ||||
| ||||
app = FastAPI()
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Обработчик GET-запросов по адресу / (корневой каталог)</span>
| ||||
| ||||
@app.get(<span class="string">'/'</span>, <span style="background-color:rgb(216, 232, 194);">response_class=HTMLResponse</span>)
| ||||
| ||||
<span class="keyword">async</span> <span class="keyword">def</span> index():
| ||||
| ||||
<span class="keyword">return</span> <span class="string">'<h1>MT5 Manager</h1>'</span>
| ||||
| ||||
</pre> <p>Теперь строка, возвращаемая из нашей исходной функции, будет использоваться для создания объекта класса <i>HTMLResponse</i>. Именно объект такого класса будет возвращаться при вызове преобразованной декоратором функции.</p> <p>После сохранения этих изменений, запущенный ранее сервер Uvicorn автоматически перезагрузится, и мы увидим в браузере ожидаемую картину:</p> <p style="text-align:center;"> <img width="745" height="253" src="../images/165001d5-3279533943659.png" loading="lazy" alt="" style="vertical-align:middle;"></p> <p>Первая версия готова, теперь можно подумать и о расширении списка обрабатываемых маршрутов.</p> <p><br></p> <h3>Добавляем запуск/остановку терминала<br></h3> <p>Не будем сильно форсировать события и сначала отработаем запуск и остановку единственного заданного экземпляра терминала. Предположим, что на компьютере, где мы планируем запускать наш веб-сервер, есть установленный терминал, расположенный по адресу </p> <pre class="code">C:/Program Files/MetaTrader 5/terminal64.exe</pre> <p>Попробуем добиться того, чтобы он мог запускаться и останавливаться при оправке соответствующих запросов к веб-серверу. Для этого добавим в таблицу маршрутов два новых:</p> <table cellspacing="0" cellpadding="3" width="100%" class="standart"> <thead> <tr> <th>#</th> <th>Метод</th> <th>URL</th> <th>Описание, параметры</th> <th>Формат<br>ответа </th> </tr> </thead> <tbody> <tr> <td>1</td> <td>GET</td> <td>/</td> <td>Отображает главную страницу с веб-интерфейсом для управления экземплярами MetaTrader 5<br>Пока что пусть отображается только заголовок с названием проекта.</td> <td>HTML</td> </tr> <tr> <td>2 </td> <td style="background-color:rgb(216, 232, 194);">POST</td> <td style="background-color:rgb(216, 232, 194);">/start </td> <td>Запускает экземпляр терминала MetaTrader 5 по установленному пути.</td> <td>JSON</td> </tr> <tr> <td>3</td> <td style="background-color:rgb(216, 232, 194);">POST<span style="color:rgb(216, 232, 194);"><br></span></td> <td style="background-color:rgb(216, 232, 194);">/stop </td> <td>Останавливает запущенный процесс MetaTrader 5</td> <td>JSON</td> </tr> </tbody> </table> <p>Мы поставили для этих маршрутов формат ответа JSON, поскольку не хотим, чтобы в результате их обработки формировалась какая-то HTML-страница, которую будет показывать браузер. По смыслу это должна была бы быть страница с сообщением о результате запуска, но эти результаты мы сможем увидеть в будущем на главной странице, возвращаемой в результате обработки первого маршрута. Для большей наглядности кода мы можем явно указать, что результатом будет объект ответа в формате JSON. </p> <p>Импортируем ещё один класс из фреймворка:</p> <pre c
| ||||
| ||||
@app.post(<span class="string">'/start'</span>)
| ||||
| ||||
<span class="keyword">async</span> <span class="keyword">def</span> start_instance():
| ||||
| ||||
result = start_mt5()
| ||||
| ||||
<span class="keyword">return</span> JSONResponse(result)
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Обработчик POST /stop - остановка терминала</span>
| ||||
| ||||
@app.post(<span class="string">'/stop'</span>, response_class=JSONResponse)
| ||||
| ||||
<span class="keyword">async</span> <span class="keyword">def</span> stop_instance():
| ||||
| ||||
result = stop_mt5()
| ||||
| ||||
<span class="keyword">return</span> JSONResponse(result)</pre> <p>Функции<i> start_mt5()</i> и <i>stop_mt5()</i> мы напишем чуть ниже. По названию понятно, что именно в них будет располагаться код, отвечающий за непосредственный запуск или остановку терминала MetaTrader 5.</p> <p>Но сначала мы создадим константу с полным путём к запускаемому файлу терминала и словарь для хранения информации о запущенном процессе:</p> <pre class="code"><span class="comment"># Путь к запускаемому файлу терминала</span>
| ||||
| ||||
MT5_PATH = <span class="string">'C:/Program Files/MetaTrader 5/terminal64.exe'</span>
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Словарь для хранения информации о запущенном терминале</span>
| ||||
| ||||
instances = {}</pre> <p></p> <p>Для функции запуска терминала нам пригодится библиотека subprocess, поэтому подключим её вначале, а затем уже реализуем саму функцию <i>start_mt5()</i>. Запуск реализуем пока через вызов функции <i>Popen()</i>, которой надо передать список аргументов командной строки запуска. У нас этот список будет состоять из единственного элемента — полного пути к файлу терминала MetaTrader 5.</p> <pre class="code"><span class="keyword">import</span> subprocess
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Запуск терминала</span>
| ||||
| ||||
<span class="keyword">def</span> start_mt5():
| ||||
| ||||
<span class="comment"># Запускаем новый процесс</span>
| ||||
| ||||
process = subprocess.Popen([MT5_PATH])
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Идентификатор запущенного процесса</span>
| ||||
| ||||
pid = process.pid
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Запоминаем идентификатор и статус процесса</span>
| ||||
| ||||
instances[<span class="string">'default'</span>] = {<span class="string">'pid'</span>: pid, <span class="string">'status'</span>: <span class="string">'running'</span>}
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Возвращаем результат - успешный запуск</span>
| ||||
| ||||
<span class="keyword">return</span> {<span class="string">'instance'</span>: <span class="string">'default'</span>, <span class="string">'pid'</span>: pid}</pre> <p>Использование такого способа запуска позволяет сейчас более легко реализовать функцию остановки терминала. Так как при запуске мы запоминаем идентификатор нового процесса (pid), то для его остановки мы можем воспользоваться библиотекой psutil, в которой есть класс представления запущенных процессов. В функции <i>stop_mt5()</i> мы создаёт объект этого класса по идентификатору нужного процесса и затем останавливаем его, вызывая метод <i>terminate()</i>.</p> <p>Если всё в порядке, то возвращаем информацию об успешной остановке, иначе — о том, что процесс не найден.</p> <pre class="code"><span class="keyword">import</span> psutil
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Остановка терминала</span>
| ||||
| ||||
<span class="keyword">def</span> stop_mt5():
| ||||
| ||||
<span class="comment"># Если ранее терминал был запущен </span>
| ||||
| ||||
<span class="keyword">if</span> <span class="string">'default'</span> <span class="keyword">in</span> instances:
| ||||
| ||||
<span class="comment"># Берём идентификатор его процесса</span>
| ||||
| ||||
pid = instances[<span class="string">'default'</span>][<span class="string">'pid'</span>]
| ||||
| ||||
<span class="keyword">try</span>:
| ||||
| ||||
<span class="comment"># Получаем объект процесса терминала</span>
| ||||
| ||||
p = psutil.Process(pid)
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Останавливаем процесс</span>
| ||||
| ||||
p.terminate()
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Удаляем информацию о запущенном ранее процессе</span>
| ||||
| ||||
<span class="keyword">del</span> instances[<span class="string">'default'</span>]
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Возвращаем результат - успешная остановка</span>
| ||||
| ||||
<span class="keyword">return</span> {<span class="string">'status'</span>: <span class="string">'stopped'</span>, <span class="string">'instance'</span>: <span class="string">'default'</span>}
| ||||
| ||||
<span class="keyword">except</span> psutil.NoSuchProcess:
| ||||
| ||||
<span class="comment"># Если процесс не найден, то</span>
| ||||
| ||||
<span class="comment"># удаляем информацию о запущенном ранее процессе</span>
| ||||
| ||||
<span class="keyword">del</span> instances[<span class="string">'default'</span>]
| ||||
| ||||
| ||||
| ||||
<span class="comment"># Возвращаем результат - процесс не найден</span>
| ||||
| ||||
<span class="keyword">return</span> {<span class="string">'status'</span>: <span class="string">'not found'</span>, <span class="string">'instance'</span>: <span class="string">'default'</span>}</pre> <p></p> <p>Сохраним сделанные изменения в файле <i>main.py</i> и посмотрим, как можно протестировать добавленную функциональность.</p> <p><br></p> <h3>Документация и тестирование<br></h3> <p>Если первый маршрут мы вполне можем протестировать, просто указав нужный URL в адресной строке, то второй и третий так протестировать не получится. Это связано с тем, что при отправке запроса из адресной строки браузер всегда использует GET-запросы. А нам теперь нужно проверить, как работают конечные точки, до которых можно достучаться только POST-запросом. К счастью, фреймворк нам поможет и в этом случае.</p> <p>FastAPI автоматически генерирует интерактивную документацию API на основе декораторов и аннотаций типов в коде. Также FastAPI включает встроенную поддержку Swagger UI (Swagger или OpenAPI — это стандарт описания REST API), которая позволяет просматривать и тестировать все конечные точки прямо в браузере. Это очень удобно, особенно если мы будем впоследствии использовать этот API в других веб-приложениях.</p> <p>Для получения доступа к этому инструменту, достаточно после запуска приложения веб-сервера открыть страницу по адресу http://127.0.0.1:8000/docs. Хотя мы сами не добавляли такую конечную точку, она доступна в любом веб-приложении, созданном с использованием фреймворка FastAPI.</p> <p>При заходе на эту страницу, мы увидим список всех конечных точек (маршрутов) нашего веб-сервера, которые были добавлены в веб-приложении:</p> <p style="text-align:center;"> <img width="745" height="429" src="../images/572b0106-763877442448.png" loading="lazy" alt="" style="vertical-align:middle;"></p> <p>Каждый маршрут можно раскрыть и увидеть список параметров с описаниями, формат ответа. У наших маршрутов пока параметров нет, поэтому мы видим пока только "No parameters":</p> <p style="text-align:center;"> <img width="745" height="602" src="../images/606aa650-278754313988.png" loading="lazy" alt="" style="vertical-align:middle;"></p> <p>Но самое интересное, это конечно же возможность вручную послать запрос к каждой конечной точке и посмотреть результат. Для этого нажмем в открытом маршруте на кнопку "Try it out" и затем на появившуюся кнопку "Execute".</p> <p style="text-align:center;"> <img width="745" height="636" src="../images/f8924413-3760065396923.png" loading="lazy" alt="" style="vertical-align:middle;"></p> <p>В результате мы увидим полученный от веб-сервера ответ с кодом 200 (OK) и информацию о запущенном экземпля
| ||||
| ||||
</div>
|