English 中文 Español Deutsch 日本語 Português
preview
Разработка MQTT-клиента для MetaTrader 5: методология TDD (Часть 3)

Разработка MQTT-клиента для MetaTrader 5: методология TDD (Часть 3)

MetaTrader 5Интеграция | 2 февраля 2024, 13:16
499 0
Jocimar Lopes
Jocimar Lopes

"Как можно называть себя профессионалом, если вы не знаете, что весь ваш код работает? А как можно знать, что весь ваш код работает, если вы не тестируете его при каждом внесении изменений? А как тестировать код при каждом внесении изменений, не имея автоматизированных юнит-тестов с очень высоким покрытием? Но можно ли создать автоматизированные модульные тесты с очень высоким покрытием без применения TDD?" (Роберт "Дядюшка Боб" Мартин, "Чистый код", 2011)

Введение

В первых двух частях серии мы имели дело с небольшой частью нерабочего раздела протокола MQTT. Мы организовали в двух отдельных заголовочных файлах все определения протоколов, перечисления и некоторые общие функции, которые будут использоваться всеми нашими классами. Кроме того, мы написали интерфейс, который будет выступать в качестве корня иерархии объектов, и реализовали его в одном классе с единственной целью — создать соответствующий пакет MQTT CONNECT. Одновременно мы написали юнит-тесты для каждой функции, участвующей в создании пакетов. Хотя мы отправили сгенерированный пакет нашему локальному MQTT-брокеру, чтобы проверить, будет ли он распознан как правильно сформированный пакет MQTT, технически этот шаг не требовался. Поскольку мы использовали определенные данные для передачи параметров нашей функции, мы знали, что тестируем их изолированно независимым от состояния способом. Это хорошо, и мы будем стремиться продолжать писать наши тесты – и, следовательно, наши функции – именно таким образом. Это сделает наш код более гибким и позволит нам изменять реализацию функции, даже не меняя тестовый код, при условии, что у нас одна и та же сигнатура функции.

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

Чтобы справиться с этой задачей, мы должны определить тип пакета сервера в первом байте ответа. Если это пакет CONNACK, мы должны прочитать его код причины подключения (Connect Reason Code) и отреагировать соответствующим образом.


(CONNECT) Установка флагов подключения клиента

Когда наш клиент запрашивает соединение с сервером, он должен сообщить серверу о

  • некоторых желаемых возможностях брокера,
  • потребуется ли аутентификация с использованием имени пользователя и пароля,
  • и предназначено ли это соединение для нового сеанса или возобновления ранее открытого сеанса.

Это делается путем установки нескольких битовых флагов в начале заголовка переменной, сразу после имени и версии протокола. Эти битовые флаги в пакете CONNECT называются флагами подключения (Connect Flags).

Помните, что битовые флаги - логические значения. Им могут быть присвоены разные имена или представления, но логические значения имеют только два возможных значения, обычно true или false.

Рис. 01. Общие термины, используемые для представления логических значений

Рис. 01. Общие термины, используемые для представления логических значений

Стандарт OASIS последовательно использует 1 (единицу) и 0 (ноль). Большую часть времени будем использовать true и false, и в конечном итоге - set и unset. Это должно сделать текст более читабельным. Более того, наш общедоступный API последовательно использует true и false для установки этих значений, поэтому использование этих терминов должно сделать эту статью более понятной для тех читателей, которые следят за развитием библиотеки.

Рис. 02. Биты флагов подключения OASIS

Рис. 02. Биты флагов подключения OASIS

Как вы можете видеть в таблице OASIS на изображении выше, первый бит (bit_0) зарезервирован, и мы должны оставить его без изменений: обнуленным, непроверенным, логическое значение false, unset. Если мы установим его, у нас будет искаженный пакет.


Clean Start (bit_1)

Первый бит, который мы можем установить, — это второй бит. Он используется для установки флага Clean Start — если он равен true, сервер выполнит Clean Start и отменит любой существующий сеанс, связанный с нашим идентификатором клиента. Сервер начнет новый сеанс. Если не установлено, сервер возобновит наш предыдущий разговор, если таковой был, или начнет новый сеанс, если с нашим идентификатором клиента не связан ни один существующий сеанс.

Вот как сейчас выглядит наша функция установки/снятия этого флага.

void CPktConnect::SetCleanStart(const bool cleanStart)
  {
   cleanStart ? m_connect_flags |= CLEAN_START : m_connect_flags &= ~CLEAN_START;
   ArrayFill(ByteArray, ArraySize(ByteArray) - 1, 1, m_connect_flags);
  }

Мы переключаем значения с помощью побитовых операций. Мы используем тернарный оператор для переключения логического значения и операции присваивания, чтобы сделать код более компактным. Затем мы сохраняем результат в члене приватного класса m_connect_flags. Наконец, мы обновляем массив байтов, представляющий наш пакет CONNECT, новыми значениями, вызывая встроенную функцию ArrayFill. (Этот поздний этап использования — заполнение массива — мы, вероятно, изменим позже.)

Строка из одного из наших тестов показывает, как она называется.

   CPktConnect *cut = new CPktConnect(buf);
//--- Act
   cut.SetCleanStart(true);


Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X X X X X X 1 0

Таблица 01. Clean Start (bit_1) битовый флаг установлен в значение true – MQTT v5.0

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

Следующие три флага с названием, начинающимися на Will, предназначены для добавления серверу тех или иных возможностей. Они сообщают серверу, что мы "хотим", чтобы сервер мог

  1. сохранять Will-сообщения и связывать их с нашим клиентским сеансом (подробнее об этом позже);
  2. обеспечить определенный уровень QoS, обычно выше QoS 0, который является значением по умолчанию, если ничего не установлено;
  3. сохранить сообщение(я) и опубликовать их как "сохраненные" (см. ниже), если для Will-сообщения установлено true


Will Flag (bit_2)

Третий бит используется для установки флага Will. Если установлено значение true, наш клиент должен предоставить Will-сообщение для публикации "в случаях, когда сетевое соединение не закрывается нормально". Will-сообщение - это своего рода "последние слова" клиента при столкновении с подписчиками.

void CPktConnect::SetWillFlag(const bool willFlag)
  {
   willFlag ? m_connect_flags |= WILL_FLAG : m_connect_flags &= ~WILL_FLAG;
   ArrayFill(ByteArray, ArraySize(ByteArray) - 1, 1, m_connect_flags);
  }

Оно вызывается так же, как и предыдущая функция.

//--- Act
   CPktConnect *cut = new CPktConnect(buf);
   cut.SetWillFlag(true);


Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X X X X X 1 X 0

Таблица 02. Битовый флаг Will (bit_2) при значении true – MQTT v5.0

Will QoS (bit_3, bit_4) 

В отличие от двух предыдущих флагов, эта функция требует установки двух битов, если клиент запрашивает уровень QoS 2, четвертый и пятый биты. QoS означает качество обслуживания (Quality of Service) и может быть одним из трех.

Рис. 03. Определения OASIS QoS

Рис. 03. Определения OASIS QoS

От наименее надежной до самой надежной системы доставки:

QoS 0

QoS 0 устанавливается в основном в момент доставки. Своего рода "выстрелил и забыл". Отправитель делает одну попытку. Сообщение может быть потеряно. Подтверждения от сервера нет. Это значение по умолчанию, то есть если в битах 3 и 4 ничего не установлено, то уровень QoS, запрашиваемый клиентом, равен QoS 0.

QoS 1 

QoS 1 устанавливается по меньшей мере в момент доставки. Имеется PUBACK, подтверждающий доставку.

Тот же шаблон определения функции.

void CPktConnect::SetWillQoS_1(const bool willQoS_1)
  {
   willQoS_1 ? m_connect_flags |= WILL_QOS_1 : m_connect_flags &= ~WILL_QOS_1;
   ArrayFill(ByteArray, ArraySize(ByteArray) - 1, 1, m_connect_flags);
  }

Тот же шаблон вызова функции.

//--- Act
   CPktConnect *cut = new CPktConnect(buf);
   cut.SetWillQoS_1(true);

Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X X X X 1 X X 0

Таблица 03. Битовый флаг Will QoS 1 (bit_3) при значении true – MQTT v5.0

QoS 2 

QoS 2 устанавливается точно в момент доставки. Требует отсутствия потерь и дублирования. Отправитель подтвердит сообщение с помощью PUBREC, а доставку — с помощью PUBREL.

Этот уровень можно представить как отправку заказной посылки. Почтовая система выдает вам квитанцию, когда вы передаете посылку им в руки, признавая, что на данный момент они несут ответственность за доставку ее по нужному адресу. И когда это происходит, когда они доставляют посылку, они отправляют вам подписанную квитанцию от получателя, подтверждающую доставку посылки.

То же самое.

void CPktConnect::SetWillQoS_2(const bool willQoS_2)
  {
   willQoS_2 ? m_connect_flags |= WILL_QOS_2 : m_connect_flags &= ~WILL_QOS_2;
   ArrayFill(ByteArray, ArraySize(ByteArray) - 1, 1, m_connect_flags);
  }

Там же.

//--- Act
   CPktConnect *cut = new CPktConnect(buf);
   cut.SetWillQoS_2(true);
Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X X X 1 X X X 0

Таблица 04. Битовый флаг Will QoS 2 (bit_4) установлен в значение true – MQTT v5.0

Сервер сообщит нам о принятом максимальном уровне QoS в кодах причин и свойствах CONNACK. Клиент может запрашивать, но возможности сервера обязательны. Если мы получаем CONNACK с максимальным QoS, мы должны соблюдать это ограничение сервера и не отправлять PUBLISH с более высоким QoS. В противном случае сервер отключится (DISCONNECT).

QoS 2 — это самый высокий уровень QoS, доступный в MQTT v5.0, и с ним связаны определенные трудности, поскольку протокол доставки симметричен, а это означает, что любая из сторон (сервер и клиент) может в этом случае выступать как отправителем, так и получателем

ПРИМЕЧАНИЕ: Можно сказать, что QoS — это ядро протокола с точки зрения пользователя. Он определяет профиль приложения и влияет на десятки других аспектов протокола. Итак, мы углубимся в уровень QoS и его настройки в контексте реализации пакета PUBLISH.

Стоит отметить, что QoS 1 и QoS 2 являются необязательными для клиентских реализаций. Как говорит OASIS в ненормативном комментарии:

"Клиенту не обязательно поддерживать пакеты QoS 1 или QoS 2 PUBLISH. В этом случае Клиент просто ограничивает максимальное поле QoS в любых отправляемых им командах SUBSCRIBE значением, которое он может поддерживать".


Will RETAIN (bit_5)

В шестом байте мы устанавливаем флаг Will Retain. Этот флаг связан с вышеупомянутым флагом Will. 

  • Если флажок Will не установлен (unset), то Will Retain также должен быть отключен.
  • Если флаг Will установлен, а параметр Will Retain нет, сервер опубликует сообщение Will как несохраненное сообщение.
  • Если установлены оба параметра, сервер опубликует сообщение Will как сохраненное сообщение.

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

Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X X 1 X X X X 0

Таблица 05. Битовый флаг Will Retain (bit_5) при значении true – MQTT v5.0

Мы должны дождаться, пока пакет CONNACK проверит этот флаг, прежде чем начинать отправлять пакеты PUBLISH. Если сервер получает пакет PUBLISH с параметром Will Retain, установленным на 1, и он не поддерживает сохраненные сообщения, сервер ОТКЛЮЧИТСЯ. Можно ли начать публикацию еще до получения пакета CONNACK? Да, можно. Стандарт допускает такое поведение. Но в нем также сделано такое замечание:

"Клиенты, которые отправляют пакеты управления MQTT до получения CONNACK, не будут знать об ограничениях сервера"

Таким образом, мы должны проверить этот флаг в пакетах CONNACK перед отправкой любых пакетов PUBLISH с параметром Will Retain, установленным на 1 (один). 


Password flag (bit_6)

В седьмом бите мы сообщаем серверу, будем ли мы отправлять пароль в полезной нагрузке (Payload) или нет. Если этот флаг установлен, в полезных данных должно присутствовать поле пароля. Если он не установлен, поле пароля не должно присутствовать в полезных данных.

"Эта версия протокола позволяет отправлять пароль без имени пользователя, чего не делает MQTT v3.1.1. Это отражает обычное использование пароля для учетных данных, отличных от пароля.” (Стандарт OASIS, 3.1.2.9)

Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X 1 X X X X X 0

Таблица 06. Битовый флаг Password Flag (bit_6) при значении true – MQTT v5.0


User Name flag (bit_7)

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

Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


1 X X X X X X 0

Таблица 07. Битовый флаг User Name (bit_7) равен true – MQTT v5.0

Итак, байт флагов подключения со следующей последовательностью битов…

Бит 7 6 5 4 3 2 1 0

User Name Flag (флаг имени пользователя)
Password Flag (флаг пароля)
Will Retain (сохранение Will)
Will  QoS 2
Will QoS 1

Will Flag (флаг Will)

Clean Start (чистый старт)


Reserved (резерв)


X X 1 1 X 1 1 0

Table 08. Установлены битовые флаги для Clean Start, Will Flag, Will QoS2 и Will Retain – MQTT v5.0

... можно перевести примерно так: открой соединение для нового сеанса с уровнем QoS 2 и будь готов сохранить мое Will-сообщение и опубликовать его как сохраненное. И, кстати, мистер Сервер, мне не понадобится проходить аутентификацию с помощью имени пользователя и пароля.

Сервер любезно ответит, сможет ли выполнить наш запрос. Он может быть в состоянии выполнить его полностью, частично или не выполнить вообще. Сервер отправит свой ответ в виде кодов причины подключения (Connect Reason Code) в пакетах CONNACK.


(CONNACK) Получение кодов причин, связанных с флагами подключения

В MQTT v5.0 имеется сорок четыре кода причины. Мы собрали их в заголовке Defines.mqh. CONNACK (и другие типы пакетов) имеют один код причины как часть заголовка переменной. Именно они и называются кодами причин подключения.

Значение Hex Наименование кода причины Описание
0 0x00 Success (успешно) Соединение принято.
128 0x80 Unspecified error (неизвестная ошибка) Сервер не желает раскрывать причину сбоя, или ни один из других кодов причины не применяется.
129 0x81 Malformed Packet (искаженный пакет) Данные в пакете CONNECT не удалось правильно распарсить. 
130 0x82 Protocol Error (ошибка протокола) Данные в пакете CONNECT не соответствуют спецификации.
131 0x83 Implementation specific error (специфическая ошибка реализации) CONNECT действителен, но не принимается сервером.
132 0x84 Unsupported Protocol Version (неподдерживаемая версия протокола) Сервер не поддерживает запрошенную Клиентом версию протокола MQTT.
133 0x85 Client Identifier not valid (идентификатор клиента недействителен) Идентификатор клиента является допустимой строкой, но не разрешен сервером.
134 0x86 Bad User Name or Password (неверное имя пользователя или пароль) Сервер не принимает имя пользователя или пароль, указанные Клиентом.
135 0x87 Not authorized (нет авторизации) Клиент не авторизован для подключения.
136 0x88 Server unavailable (сервер недоступен) Сервер MQTT недоступен.
137 0x89 Server busy (сервер занят) Сервер занят. Попробуйте позже.
138 0x8A Banned (запрещено) Клиент заблокирован администратором. Свяжитесь с администратором сервера.
140 0x8C Bad authentication method (неверный метод аутентификации) Метод аутентификации не поддерживается или не соответствует методу аутентификации, используемому в данный момент.
144 0x90 Topic Name invalid (неверное название темы) Имя темы Will не является искаженным, но не принимается сервером.
149 0x95 Packet too large (слишком большой пакет) Пакет CONNECT превысил максимально допустимый размер.
151 0x97 Quota exceeded (превышена квота) Превышен предел, установленный во время реализации или администратором.
153 0x99 Payload format invalid (неверный формат полезной нагрузки) Полезная нагрузка Will не соответствует указанному индикатору формата полезной нагрузки (Payload Format Indicator).
154 0x9A Retain not supported (сохранение не поддерживается) Сервер не поддерживает сохраненные сообщения, и для Will Retain установлено значение 1.
155 0x9B QoS not supported (QoS не поддерживается) Сервер не поддерживает QoS, установленный в Will QoS.
156 0x9C Используйте другой сервер Клиенту следует временно использовать другой сервер.
157 0x9D Server moved (сервер переехал) Клиент должен использовать другой сервер.
159 0x9F Connection rate exceeded (превышена скорость соединения) Превышен лимит скорости соединения.

Таблица 08. Значения кодов причины подключения

В стандарте четко указано, что сервер должен отправлять коды причины подключения по CONNACK:

"Сервер, отправляющий пакет CONNACK, ДОЛЖЕН использовать одно из значений кода причины подключения [MQTT-3.2.2-8]."

На данном этапе для нас особый интерес представляют коды причин подключения. Нам нужно проверить их, прежде чем продолжить коммуникацию. Они сообщат нам о некоторых возможностях и ограничениях сервера, таких как доступный уровень QoS и доступность сохраненных сообщений. Кроме того, как вы можете видеть по их именам и описаниям в таблице выше, они сообщат нам, была ли наша попытка подключения (CONNECT) успешной или нет.

Чтобы получить коды причин, сначала нам нужно определить тип пакета, поскольку нас интересуют только пакеты CONNACK.

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


(CONNACK) Определение типа пакета сервера

Мы точно знаем, что первый байт любого управляющего пакета MQTT кодирует тип пакета. Итак, мы просто прочитаем этот первый байт как можно скорее, и у нас есть тип пакета сервера.

uchar pkt_type = server_response_buffer[0];

Код понятен, переменные названы правильно. Никаких проблем не ожидается.

Но подождите! Как код, который будет использовать нашу библиотеку, должен вызывать этот оператор? Тип пакета будет возвращен вызовом публичной функции? Или эта информация может быть скрыта приватным членом как деталь реализации? Если он возвращается вызовом функции, где должна размещаться эта функция? В классе CPktConnect? Или его следует разместить в любом из наших заголовочных файлов, поскольку он будет использоваться многими разными классами? Если он хранится в приватном члене, в каком классе он должен находиться?

Есть известный акроним - TMTOWTDI* (there is more than one way to do it, есть несколько способов сделать это). TDD — еще одна аббревиатура, ставшая очень популярной по разным причинам. Оба акронима активно раскручивались и даже вошли в моду:

__ "I’m tddying, mom! It’s cool" ("Мама, я tddю! Это круто!")

Это было сделано после многих лет напряженной работы над одним и тем же основным вопросом: как писать более производительный, идиоматический и надежный код, одновременно повышая продуктивность разработчиков? Как заставить разработчиков сосредоточиться на том, что необходимо сделать, а не блуждать вокруг того, что можно сделать? Как заставить каждого из них сосредоточиться на одной задаче – и только на одной задаче – одновременно? Как быть уверенным, что их действия не приведут к возникновению ошибок регрессии и поломке системы? 

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

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

Как мы можем определить, является ли ответ сервера пакетом CONNACK? Легко. Он имеет определенный тип, закодированный в заголовке MQTT.mqh как перечисление (Enumeration), а именно ENUM_PKT_TYPE.

//+------------------------------------------------------------------+
//|              MQTT - CONTROL PACKET - TYPES                       |
//+------------------------------------------------------------------+
/*
Position: byte 1, bits 7-4.
Represented as a 4-bit unsigned value, the values are shown below.
*/
enum ENUM_PKT_TYPE
  {
   CONNECT     =  0x01, // Connection request
   CONNACK     =  0x02, // Connection Acknowledgment
   PUBLISH     =  0x03, // Publish message
...

Попробуем начать с функции, которая при передаче сетевого массива байтов, поступающего от MQTT-брокера, возвращает тип пакета.

Звучит неплохо. Напишем тест для этой функции.

bool TEST_GetPktType_FAIL()
  {
   Print(__FUNCTION__);
//--- Arrange
   uchar expected[] = {(uchar)CONNACK};
   uchar result[1] = {};
   uchar wrong_first_byte[] = {'X'};
//--- Act
   CSrvResponse *cut = new CSrvResponse();
   ENUM_PKT_TYPE pkt_type = cut.GetPktType(wrong_first_byte);
   ArrayFill(result,0,1,(uchar)pkt_type);
//--- Assert
   bool isTrue = AssertNotEqual(expected, result);
//--- cleanup
   delete cut;
   ZeroMemory(result);
   return  isTrue ? true : false;
  }

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

В комментариях мы описываем каждый шаг паттерна: 

Arrange (упорядочивание)

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

Во-вторых, мы инициализируем пустой буфер символов размером в единицу, чтобы получить результат вызова нашей функции.

Мы инициализируем наше приспособление для передачи в тестируемую функцию. Он обозначает первый одиночный байт, который необходимо прочитать из фиксированного заголовка ответа сервера, чтобы определить тип пакета. В этом случае он имеет значение wrong_first_byte. Мы делаем его явным, называя переменную соответствующим образом.

Акт (действие)

Мы создаем экземпляр тестируемого класса (cut) и вызываем нашу функцию.

Assert (утверждение)

Мы утверждаем неравенство ожидаемого и результирующего массивов как по содержанию, так и по размеру с помощью функции MQL5 ArrayCompare (см. прикрепленный тестовый файл).

Clean-Up (очистка)

Наконец, мы очищаем ресурсы, удаляя "вырезанный" экземпляр и передавая наш буфер "результата" в ZeroMemory. Это позволит избежать утечек памяти и загрязнения тестов.

Рис. 04. TEST_CSrvResponse - FAIL - необъявленный идентификатор

Рис. 04. TEST_CSrvResponse - FAIL - необъявленный идентификатор

При компиляции происходит сбой, поскольку функция еще не существует. Нам нужно ее написать. Но где ее следует разместить?

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

Это класс CSrvResponse, реализующий интерфейс IControlPacket.

У нас может возникнуть соблазн сделать его еще одной функцией в нашем уже существующем классе CPktConnect. Но мы нарушили бы важный принцип объектно-ориентированного программирования: принцип единственной ответственности (Single Responsibility Principle, SRP).

"Вам следует разделить те вещи, которые меняются по разным причинам, и сгруппировать вместе те вещи, которые меняются по одним и тем же причинам" (Р. Мартин, "Чистый код", 2011).

С одной стороны, наш класс CPktConnect будет меняться всякий раз, когда мы меняем способ построения пакетов CONNECT, а с другой стороны, наш (несуществующий) класс CSrvResponse будет меняться всякий раз, когда мы меняем способ чтения наших CONNACK, PUBACK, SUBACK и других ответов сервера. Таким образом, у них есть совершенно разные обязанности, и в данном случае это довольно легко увидеть. Но иногда может быть сложно решить, следует ли объявлять объект предметной области, которую мы моделируем, в соответствующем классе. Применяя SRP, вы получаете объективное руководство для принятия решения.

Итак, давайте попробуем внести минимальные изменения, чтобы пройти тест.

ENUM_PKT_TYPE CSrvResponse::GetPktType(uchar &resp_buf[])
  {
   return (ENUM_PKT_TYPE)resp_buf[0];
  }

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

Рис. 05. TEST_CSrvResponse - FAIL - неверный пакет

Рис. 05. TEST_CSrvResponse - FAIL - неверный пакет

Давайте передадим "правильный" тип пакета CONNACK в качестве ответа сервера. Обратите внимание, что мы снова явно присваиваем имя: right_first_byte. Имя само по себе является всего лишь ярлыком. Важно то, чтобы его смысл был ясен каждому, кто читает наш код. Включая и нас самих шесть месяцев или шесть лет спустя.

bool TEST_GetPktType()
  {
   Print(__FUNCTION__);
//--- Arrange
   uchar expected[] = {(uchar)CONNACK};
   uchar result[1] = {};
   uchar right_first_byte[] = {2};
//--- Act
   CSrvResponse *cut = new CSrvResponse();
   ENUM_PKT_TYPE pkt_type = cut.GetPktType(right_first_byte);
   ArrayFill(result,0,1,(uchar)pkt_type);
//--- Assert
   bool isTrue = AssertEqual(expected, result);
//--- cleanup
   delete cut;
   ZeroMemory(result);
   return  isTrue ? true : false;
  }

Рис. 06. TEST_CSrvResponse - PASS

Рис. 06. TEST_CSrvResponse - PASS

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

В этих простых шагах заложены три основных "закона" TDD, кратко изложенные Р. Мартином.

  1. Вы не можете писать какой-либо готовый код до тех пор, пока вы не напишете неудачный юнит-тест.
  2. Вам не разрешается писать больше юнит-тестов, чем достаточно для того, чтобы их провалить, а неудачная компиляция означает провал.
  3. Вам не разрешается писать больше готового кода, чем достаточно для прохождения текущего неудачного юнит-теста.

Хорошо. Пока оставим тему TDD. Давайте вернемся к нашей задаче и прочитаем коды причин подключения в пакетах CONNACK, поступающих с сервера.


(Connect Reason Codes) Что делать с недоступными возможностями на сервере?

На данном этапе нашего внимания заслуживают два кода причин подключения.

  1. QoS not supported (QoS не поддерживается)
  2. Retain not supported (сохранение не поддерживается)

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

Что нам делать, если мы отправим CONNECT с QoS 2, а сервер ответит QoS Maximum 1? Должны ли мы повторно отправить CONNECT с пониженным флагом QoS? Или нам следует отключиться (DISCONNECT) перед понижением версии? Если так было с функцией RETAIN, можем ли мы просто проигнорировать ее как неактуальную и все равно начать публикацию? Или нам следует повторно отправить CONNECT с пониженными флагами перед публикацией?

Что нам следует делать после того, как мы получим успешное сообщение CONNACK, означающее, что сервер принял наше соединение и обладает всеми запрошенными нами возможностями? Должны ли мы немедленно начать отправлять пакеты PUBLISH? Или мы можем оставить соединение открытым, отправляя последовательные пакеты PINGREQ, пока не будем готовы опубликовать сообщение? Кстати, надо ли подписаться (SUBSCRIBE) на тему перед публикацией?

На большинство этих вопросов отвечает Стандарт. Необходимо внедрить AS-IS, чтобы иметь клиент, соответствующий MQTT v5.0. Разработчикам приложений предоставлено множество вариантов на выбор. На данный момент мы будем иметь дело только с тем, что необходимо, чтобы как можно скорее получить соответствующего клиента.

Согласно стандарту, клиент может запрашивать уровень QoS > 0, только если флаг Will также установлен на 1, что означает, что нам разрешено запрашивать уровень QoS > 0, только если мы также отправляем сообщение Will в пакете CONNECT. Но мы не хотим, или, лучше сказать, нам не нужно иметь дело с Will-сообщениями прямо сейчас. Итак, наше решение — это компромисс между пониманием того, что нам нужно знать сейчас, и попыткой разобраться во всех тонкостях Стандарта, в конечном итоге написав код, который в дальнейшем может не понадобиться. 

Нам нужно только знать, что будет делать наш клиент, если запрошенный или сохраненный уровень QoS недоступен на сервере. И нам нужно это знать, как только прибудет новый CONNACK. Поэтому мы делаем проверку в конструкторе CSrvResponse. Если ответом является CONNACK, конструктор вызывает защищенный метод GetConnectReasonCode.

CSrvResponse::CSrvResponse(uchar &resp_buf[])
  {
   if(GetPktType(resp_buf) == CONNACK
      && GetConnectReasonCode(resp_buf)
      == (MQTT_REASON_CODE_QOS_NOT_SUPPORTED || MQTT_REASON_CODE_RETAIN_NOT_SUPPORTED))
     {
      CSrvProfile *serverProfile = new CSrvProfile();
      serverProfile.Update("000.000.00.00", resp_buf);
     }
  }

Если код причины подключения — MQTT_REASON_CODE_QOS_NOT_SUPPORTED или MQTT_REASON_CODE_RETAIN_NOT_SUPPORTED, эта информация будет храниться в профиле сервера. Пока мы будем хранить только эту информацию о сервере и ждать отключения (DISCONNECT). Позже мы будем использовать ее при запросе нового соединения на этом же сервере. Обратите внимание, что это "позже" может произойти через несколько миллисекунд после первой попытки подключения. А может, через несколько недель. Дело в том, что эта информация будет храниться в профиле сервера.


Как мы тестируем защищенные методы?

Для тестирования защищенных методов мы создали класс в нашем тестовом сценарии, производный от нашего тестируемого класса, в данном случае CSrvResponse. Затем мы вызываем защищенные методы CSrvResponse через этот производный класс, созданный "в целях тестирования", который мы назвали TestProtectedMethods.

class TestProtectedMethods: public CSrvResponse
  {
public:
                     TestProtectedMethods() {};
                    ~TestProtectedMethods() {};
   bool              TEST_GetConnectReasonCode_FAIL();
   bool              TEST_GetConnectReasonCode();
  };
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
bool TestProtectedMethods::TEST_GetConnectReasonCode_FAIL()
  {
   Print(__FUNCTION__);
//--- Arrange
   uchar expected = MQTT_REASON_CODE_SUCCESS;
   uchar reason_code_banned[4];
   reason_code_banned[0] = B'00100000'; // packet type
   reason_code_banned[1] = 2; // remaining length
   reason_code_banned[2] = 0; // connect acknowledge flags
   reason_code_banned[3] = MQTT_REASON_CODE_BANNED;
//--- Act
   CSrvResponse *cut = new CSrvResponse();
   uchar result = this.GetConnectReasonCode(reason_code_banned);
//--- Assert
   bool isTrue = AssertNotEqual(expected, result);
//--- cleanup
   delete cut;
   ZeroMemory(result);
   return  isTrue ? true : false;
  }
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
bool TestProtectedMethods::TEST_GetConnectReasonCode()
  {
   Print(__FUNCTION__);
//--- Arrange
   uchar expected = MQTT_REASON_CODE_SUCCESS;
   uchar reason_code_success[4];
   reason_code_success[0] = B'00100000'; // packet type
   reason_code_success[1] = 2; // remaining length
   reason_code_success[2] = 0; // connect acknowledge flags
   reason_code_success[3] = MQTT_REASON_CODE_SUCCESS;
//--- Act
   CSrvResponse *cut = new CSrvResponse();
   uchar result = this.GetConnectReasonCode(reason_code_success);
//--- Assert
   bool isTrue = AssertEqual(expected, result);
//--- cleanup
   delete cut;
   ZeroMemory(result);
   return  isTrue ? true : false;
  }

Обратите внимание, что мы ничего не храним в профиле сервера. Его еще даже не существует. Мы просто печатаем сообщение о том, что профиль сервера обновляется. Это связано с тем, что профиль сервера должен сохраняться между сеансами нашего клиента, а мы пока не занимаемся сохранением. Позже, при реализации сохранения, мы можем изменить эту функцию-заглушку, чтобы сохранить профиль сервера, например, в базе данных SQLite, даже без необходимости удалять напечатанное (или зарегистрированное) сообщение. Просто сейчас это не реализовано. Как было сказано выше, на этом этапе нам нужно только знать, что делать, если сервер не соответствует нашим запрошенным возможностям: мы сохраняем информацию для повторного использования.


Заключение

В этой статье мы описали начало работы с операционной частью протокола MQTT v5.0, как того требует стандарт OASIS, чтобы как можно скорее запустить соответствующий клиент. Мы описали, как мы реализуем класс CSrvResponse для определения типа ответа сервера и связанных с ним кодов причин. Мы также описали, как наш клиент будет реагировать на недоступные возможности сервера.

На следующем этапе мы реализуем PUBLISH, лучше поймем рабочее поведение для уровней QoS и разберемся с сеансами (Sessions) и необходимым сохранением.

** Другие полезные акронимы: DRY (don’t repeat yourself - не повторяйся), KISS (keep it simple, stupid - делай проще, тупица), YAGNI (you aren't gonna need it - тебе это не понадобится). В каждом из них есть некоторая практическая мудрость, но YMMV (your mileage may vary - на вкус и цвет товарищей нет) :) 

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/13388

Прикрепленные файлы |
headers.zip (7.16 KB)
tests.zip (3 KB)
Популяционные алгоритмы оптимизации: Эволюция социальных групп (Evolution of Social Groups, ESG) Популяционные алгоритмы оптимизации: Эволюция социальных групп (Evolution of Social Groups, ESG)
В статье рассмотрим принцип построения многопопуляционных алгоритмов и в качестве примера такого вида алгоритмов разберём Эволюцию социальных групп (ESG), новый авторский алгоритм. Мы проанализируем основные концепции, механизмы взаимодействия популяций и преимущества этого алгоритма, а также рассмотрим его производительность в задачах оптимизации.
Теория категорий в MQL5 (Часть 21): Естественные преобразования с помощью LDA Теория категорий в MQL5 (Часть 21): Естественные преобразования с помощью LDA
Эта статья, 21-я в нашей серии, продолжает рассмотрение естественных преобразований и того, как их можно реализовать с помощью линейного дискриминантного анализа. Как и в предыдущей статье, реализация представлена в формате класса сигнала.
Нейросети — это просто (Часть 75): Повышение производительности моделей прогнозирования траекторий Нейросети — это просто (Часть 75): Повышение производительности моделей прогнозирования траекторий
Создаваемые нами модели становятся все больше и сложнее. Вместе с тем растут затраты не только на их обучение, но и эксплуатацию. При этом довольно часто мы сталкиваемся с ситуацией, когда затраты времени на принятие решения бывают критичны. И в этой связи мы обращаем свое внимание на методы оптимизации производительности моделей без потери качества.
Трейлинг-стоп в трейдинге Трейлинг-стоп в трейдинге
В этой статье мы рассмотрим использование трейлинг-стопа в торговле — насколько он полезен и эффективен, и как его можно использовать. Эффективность трейлинг-стопа во многом зависит от волатильности цены и подбора уровня стоп-лосса. Для установки стоп-лосса могут использоваться самые разные подходы.