Самое популярное назначение IoT устройств это сбор телеметрии. На сегодняшний день цены на облачные IoT сервисы снизились настолько, что позволить себе их использовать может и обычный рядовой пользователь. Сегодня расскажем о том, как отправить данные в облако с платы NodeMCU используя язык Lua.
Примечание: мы продолжаем серию публикаций полных версий статей из журнала Хакер. Орфография и пунктуация автора сохранены.
Передаю слово автору.
Так как я работаю в стеке технологий Microsoft, то для создания облачной части IoT решения я использую Azure Functions и Table Storage, но мой PoC для NodeMCU и языка Lua можно использовать и с другими провайдерами облачных IoT решений.
INFO
NodeMCU от Expressif — это одна из самых недорогих плат с Wi-Fi, micro USB и программатором на борту. Она создана на базе модуля ESP8266. Плату второго поколения можно приобрести приблизительно за 6 — 7 долларов. С платой можно работать из Arduino IDE. Кроме того, плата поддерживает скриптовый язык под названием Lua (переводится с португальского как «Луна»).
Подключение и настройка девайса
Для того чтобы под Windows девайс распознался вам необходимо скачать драйвер по следующей ссылке:CP210x USB to UART Bridge VCP Drivers
Стандартная скорость последовательного порта NodeMCU это 115'200bps. Вы можете установить другую скорость, на при первом же сбросе устройства она вернется к 115200.
Важно, чтобы драйверу была установлена точно такая же скорость:
Прошивка
Скорее всего в первоначальной прошивке чего-то будет не хватать, поэтому в идеале прошить устройство самостоятельно. Собрать образ Firmware можно несколькими способами. С помощью облачного сервиса, образа Docker-а или используя инструкцию для Linux. Я собирал с помощью облачного сервиса. Вам тоже советую этот вариант.
Если нужно отправить данные в облако, то необходимым для выбора функционалом являются SNTP, MQTT, HTTP (WiFi, timer, file, GPIO, net, node, UART уже выбраны по умолчанию). Также необходимо пометить в качестве необходимого TLS/SSL support в пункте Miscellaneous options
Cсылка с bin файлом приходит на почту. Точнее сказать, приходят даже сразу 2 ссылки. Одна с образом, поддерживающим операции с плавающей запятой, и вторая с не поддерживающим.
Перед прошивкой ESP8266 необходимо привести в особый режим. На плате имеется отдельная кнопка FLASH. Ее нажатие во время включения питания или нажатия reset приводит девайс в режим bootloader. Если вдруг в вашей модификации платы такой кнопки нет, то перед прошивкой вам необходимо соединить GPIO0 с GND и нажать reset (этот способ подходит для ESP-12).
Прошить Firmware можно утилитой PyFlasher. Py в названии означает, что приложение написано на Python. Есть еще nodemcu-flasher, но она давно уже не обновлялась. Ее я не пробовал.
Окно PyFlasher выглядит так:
Flash mode выбирается в зависимости от того какая у вас плата. Большинству современных плат на базе модулей ESP8266 ESP-12 и ESP32 подходит режим DIO. ESP8266 от 01 до 07 подходит более быстрый режим QIO. DOUT используется ESP8285.
Настройка IDE
Скачиваем бесплатную IDE по ссылке ESPlorer. В качестве альтернативы есть ZeroBrane Studio. Мне больше всего по душе пришелся ESPlorer, поэтому приведу пример работы именно с ним. ESPlorer написан на JAVA. Интерфейс приложения такой
С левой стороны код, настройки и какой-то другой подобный функционал. С правой – окно мониторинга и команды управления девайсом. Открываем приложение, выбираем порт. Устанавливаем скорость, на которой будет происходить обмен (вероятнее всего это 115200) и нажимаем Open.
Для разминки можете запустить простой скрипт, который мигает встроенным светодиодом:
LED = 0
gpio.mode(LED, gpio.OUTPUT)
function flash_led()
gpio.write(LED, gpio.LOW)
tmr.delay(500000)
gpio.write(LED, gpio.HIGH)
end
tmr.alarm(1, 1000, tmr.ALARM_AUTO, flash_led)
Если на вашей плате нет встроенного светодиода (или вам уже вконец надоели примеры моргания светодиодом =), то вы можете попробовать выполнить еще более простой скрипт, который выводит на экран строку:
print("Hello from Lua!")
После того как создадите файл формата .lua (допустим test.lua), внесете в него код и сохраните на диск вы сможете загрузить его на устройство. Для этого необходимо открыть порт если он не открыт (кнопка Open) и нажать кнопку Upload. Ее вы можете найти среди кнопок, которые расположены под кодом (слева).
Загрузив файл, вы сможете его выполнить, отправив команду:
dofile("test.lua")
Команду можно ввести вручную в нижнее поле, расположенное справа под монитором. Или если вам не хочется набирать никакой текст, то можете нажать кнопку Reload (крайний ряд кнопок справа). После нажатия этой кнопки вы получите список кнопок с загруженными на плату файлами .lua. Нажатие на кнопку с именем файла запустит файл на исполнение.
Если вы хотите, чтобы файл запускался сразу после включения платы, то создайте файл с именем init.lua.
Настройка облачной части для работы с устройством
Оставим на какое-то время наше устройство и создадим его двойник в облаке. С недавних пор двойник устройства можно создать прямо на портале Azure, воспользовавшись новым функционалом. В группе настроек IoT хаба под названием Explorers, необходимо выбрать IoT Devices и нажать “+ Add”
Для подключения устройства к IoT хабу нам необходимо сгенерировать SAS (shared access signature). Для генерации SAS используется ключ двойника устройства, который можно получить с помощью какой-либо вспомогательной утилиты (Device Explorer, iothub-explorer, IoT Extension for Azure CLI 2.0). Но проще всего получить ключ все там же, на портале Azure, зайдя в IoT Hub -> IoT Devices.
SAS можно сгенерировать на устройстве, а можно сгенерировать с помощью какого-либо своего онлайн сервиса. Если вы используете SDK, то оно может сгенерировать для вас SAS автоматически (достаточно в коде указать ключ двойника устройства).
Способ, при котором SAS токен генерируется каким-либо веб-сервисом на определенное ограниченное время чуть более безопасен. Хотя существует и определенный нюанс. Если отправлять сервису только имя устройства, то кто-то может перебором имен получить токен какого-то другого устройства. Поэтому чтобы немного обезопасить процесс предлагаю такое решение: давайте сохраним на устройстве хэш Azure ключа двойника устройства. И в коде сервиса перед генерацией SAS проверим совпадает ли хэш с хэшем ключа устройства. Таким образом получить SAS можно будет только зная имя устройства и хэш его ключа.
Первый способ, при котором SAS генерируется на устройстве более простой и удобный, но чуть менее безопасный. Так как получив доступ к устройству злоумышленник сможет получить ключ и генерировать SAS устройства самостоятельно. Во втором случае получив доступ к устройству взломщик сможет получать только SAS токены, время жизни которых ограничено.
Выходит, что оба способа по большому счету не идеальны в случае если у хакера есть доступ к устройству. Даже защита соединения с помощью VPN здесь не поможет. В таком случае канал передачи будет защищен, но тот, кто получит в руки устройство сможет получить и доступ к каналу. К сожалению, на устройствах NodeMCU, Arduino и т.п. нет возможности хранить ключи/пароли в каком-либо безопасном хранилище. Пожалуй, рынку недорогих IoT устройств требуется новый аппаратный функционал.
Создание Azure функции для генерации SAS
В качестве онлайн сервиса проще всего использовать Azure функции. Это своеобразные сниппеты, которые можно писать сразу на портале Azure в браузере. Шутки шутками, но таким образом программировать можно даже со смартфона. Конечно, никто не запрещает создавать и отлаживать их и из Visual Studio, и только потом публиковать в Azure уже в откомпилированном виде.
Задача функции выполнить какую-то как правило не особо сложную операцию. По микросервисной задумке, каждая функция умеет делать что-то одно, но зато очень хорошо (принцип Single responsibility).
Создать Azure Function App можно на портале заполнив небольшую анкету
Consumption Plan позволяет платить только за те вызовы функции, которые были совершены. Это самый недорогой вариант. На данный момент миллион вызовов функции дается бесплатно.
Заметьте, что вместе с созданием функции создается и вспомогательное хранилище данных (Storage).
После создания Function App можно создать и саму функцию. В данном случае нам нужна функция типа Webhook + API. Функция может быть открыта всем (анонимный доступ), а может быть доступна только обладателям особого кода. Код можно получить из окна работы с функцией нажав на ссылку </> Get function URL:
Функции можно писать на различных языках. Я предпочитаю C#.
using System.Net;
using Microsoft.Azure.Devices;
using Microsoft.Azure.Devices.Common.Security;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
string deviceid = req.GetQueryNameValuePairs()
.FirstOrDefault(q => string.Compare(q.Key, "deviceid", true, CultureInfo.InvariantCulture) == 0).Value;
string hash = req.GetQueryNameValuePairs()
.FirstOrDefault(q => string.Compare(q.Key, "hash", true, CultureInfo.InvariantCulture) == 0).Value;
if (String.IsNullOrEmpty(deviceid)) return req.CreateResponse(HttpStatusCode.BadRequest, "device id missing");
if (String.IsNullOrEmpty(hash)) return req.CreateResponse(HttpStatusCode.BadRequest, "hash missing");
var resourceUri ="ArduinoDemoHub.azure-devices.net/devices/"+deviceid;
// taken from IoT Hub user with Connect devices rights (not from Device Explorer)
var connectionString = "HostName=ArduinoDemoHub.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=cuYBKc42lfJr4oSRGQGQ8IiKWxGQkLre7rprZDZ/ths=";
var registryManager = RegistryManager.CreateFromConnectionString(connectionString);
var device = await registryManager.GetDeviceAsync(deviceid);
var key = device.Authentication.SymmetricKey.PrimaryKey;
HMACSHA256 hmac = new HMACSHA256(Encoding.UTF8.GetBytes("somerandomkeyKJBWyfy4gski"));
var hashedkey = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(key)));
if (hashedkey!=hash) return req.CreateResponse(HttpStatusCode.BadRequest, "wrong hash");
SharedAccessSignatureBuilder sasBuilder = new SharedAccessSignatureBuilder()
{
Key = key,
Target = resourceUri,
TimeToLive = TimeSpan.FromDays(Convert.ToDouble(7))
};
var SAS = sasBuilder.ToSignature();
return req.CreateResponse(HttpStatusCode.OK, SAS);
}
Создаем файл project.json и добавляем в него следующее содержимое:
{
"frameworks": {
"net46":{
"dependencies": {
"Microsoft.Azure.Devices": "1.4.1"
}
}
}
}
В коде используется строка подключения к IoT хабу. Ее можно попутать со строкой подключения к устройству. Чтобы вы не запутались напомню, где ее можно взять:
Необходимо взять Connection string из какого-нибудь Policy с правами Device connect.
Саму строку подключения лучше всего не указывать в коде, как это сделал я. Я это сделал исключительно ради примера. Лучше всего зайти в Application settings функции
И указать там строку подключения. После этого ее можно «доставать» из безопасного хранилища с помощью
ConfigurationManager.ConnectionStrings["Имя_вашей_строки_подключения"].ConnectionString
На устройстве нам необходимо сохранить hashedkey. Но предварительно нужно энкодировать символы. В этом нам поможет HttpUtility.UrlEncode из пространства System.Web
hashedkey = HttpUtility.UrlEncode(hashedkey);
Отправлять запрос будем с помощью Get, а не все символы могут быть переданы в качестве значения параметра.
Написание кода для отправки данных в облако
Я написал небольшой код на Lua отправляющий данные в облако. Получился своеобразный PoC. Можете его использовать и дорабатывать под свои нужды.
Создаем 2 файла init.lua и SendDataToCloud.lua
Содержимое первого:
-- Информация об устройстве
print('init.lua ver 1.2')
wifi.setmode(wifi.STATION)
print('set mode=STATION (mode='..wifi.getmode()..')')
print('MAC: '..wifi.sta.getmac())
print('chip: '..node.chipid())
print('heap: '..node.heap())
-- Настройка Wifi
station_cfg={}
station_cfg.ssid="Ваш_SSID"
station_cfg.pwd="Пароль_вашей_точки_доступа"
station_cfg.save=false
wifi.sta.config(station_cfg)
wifi_status_codes = {
[0] = "Idle",
[1] = "Connecting",
[2] = "Wrong Password",
[3] = "No AP Found",
[4] = "Connection Failed",
[5] = "Got IP"
}
sntp_connect_status_codes = {
[1] = "DNS lookup failed",
[2] = "Memory allocation failure",
[3] = "UDP send failed",
[4] = "Timeout, no NTP response received"
}
-- Ждем подключения к Wi-fi (совершаем попытки каждую секунду)
tmr.alarm(6,1000, 1, function()
if wifi.sta.getip()==nil then
print("Waiting for IP address! (Status: "..wifi_status_codes[wifi.sta.status()]..")")
else
print("New IP address is "..wifi.sta.getip())
tmr.stop(6)
-- синхронизируем часы с NTP
sntp.sync({'pool.ntp.org'},
function(sec, usec, server)
print("Clock Synced: "..sec..", "..usec..", "..server)
tls.cert.verify(false)
-- Выполняем следующий файл
dofile('SendDataToCloud.lua')
end,
function(error_code)
print("Clock Sync Failed: "..sntp_connect_status_codes[error_code])
end,
1 -- повторяем синхронизацию в случае ошибки
)
end
end
)
Этот файл совершает подключение к сети и выполняет код из файла SendDataToCloud.lua в случае успешного подключения.
Необходимо указать в качестве значений station_cfg.ssid и station_cfg.pwd данные вашей точки доступа Wi-Fi.
В следующем файле необходимо изменить имя устройства и IoT хаба (переменные DEVICE и IOTHUB). В переменной funcurl указан адрес генерирующей SAS функции и в качестве значения параметра hash указан хаш ключа девайса (который мы предварительно закодировали с помощью HttpUtility.UrlEncode)
-- Конфигурация
DEVICE = "LuaDevice"
IOTHUB = "ArduinoDemoHub.azure-devices.net"
PORT = 8883
USER = "ArduinoDemoHub.azure-devices.net/"..DEVICE.."/api-version=2016-11-14"
telemetry_topic="devices/"..DEVICE.."/messages/events/"
connected = false
local headers =
'Content-Type: application/x-www-form-urlencodedrn'..
'Accept: */*rn'..
'User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0'
funcurl = "https://arduinofunction.azurewebsites.net/api/GenerateSASFunction?code=Jn7j54PbR31BSRa0UZrDwp4ZEltjmWHmblG9zLo0Ne0tyGM7w/wQ7w=="
funcurl = funcurl.."&hash=oJzykimyQsTPtzgJxYq90Xfqmw1rZTPTCH%2bJ5sSurKI%3d"
funcurl = funcurl.."&deviceid="..DEVICE
tmr.alarm(1,5000, 1, function()
http.get(funcurl, headers,
function(code, data, header)
if (code < 0) then
print("HTTP request failed")
else
sas = true
print(code, data)
if string.match(data, "Shared") then
tmr.stop(1)
SAS = string.sub(data,2,string.len(data)-1)
print(SAS)
connect(SAS)
end
end
end)
end)
function connect(SAS)
-- Создаем клиента MQTT
client = mqtt.Client(DEVICE, 240, USER, SAS)
-- Подключаемся к IoTHub с помощью протокола MQTT
print ("Connecting to MQTT broker. Please wait...")
tmr.alarm(2,1000, 1, function()
client:connect(IOTHUB, PORT, 1,
-- Callback в случае успешного подключения
function(client)
tmr.stop(2)
print("Connected to MQTT: "..IOTHUB..":"..PORT.." as "..DEVICE)
connected = true
senddata()
end,
-- Callback в случае ошибки
function(client, reason)
print("Error Connecting: "..reason)
end
)
end)
end
function senddata()
math.randomseed(1)
tmr.alarm(3, 1000, tmr.ALARM_AUTO, publish_data)
-- Если вдруг отключимся, то будет выполнен следующий callback
client:on("offline", function(client)
print("MQTT Disconnected.")
connected = false
end)
end
-- Функция отправляющая данные в облако
function publish_data()
if connected == true then
somedata = math.random(1,100)
-- Подготавливаем данные для отправки
payload =
"{ "deviceId" : ""..DEVICE.."","..
""iotdata" :"..somedata.."}"
-- Отправляем данные
client:publish(telemetry_topic, payload, 1, 0, function(client)
print("Data published successfully.")
end)
end
end
Данные отправляются без использования Azure SDK, так что можете использовать этот код не только для отправки данных именно в Azure. Альтернатив достаточно много: AWS, Google Cloud IoT, IBM Watson IoT Platform.
В примере используется протокол MQTT (Message Queuing Telemetry Transport). Это открытый протокол, который разработан специально для IoT устройств. Данные отправляются в формате JSON. Там, где в реальных проектах данные снимаются с датчиков, в примере генерируется случайное число.
Во время процесса handshake между устройством и IoT хабом сервер может отправить свой сертификат, а может запросить сертификат устройства. Если вы помните, то прошлый раз при работе с Arduino девайсом мы прошивали его сертификатом. Сейчас же достаточно одной стоки кода:
tls.cert.verify(false)
Ограничиваемся сертификатом, который отправит нам сервер.
INFO
Вам может быть интересно, что содержимое сертификатов вашего хаба вы можете получить с помощью следующей команды OpenSSL
openssl s_client -showcerts -connect ArduinoDemoHub.azure-devices.net:8883
Для подготовки материала использовались лабы, которые доступны по ссылке: Sending Device-to-Cloud (D2C) Messages
Код уже не совсем актуальный и пришлось его немного обновить, но в целом ссылка может оказаться полезной.
Работаем с NodeMCU из Arduino IDE
Не могу обойти стороной тему использования SDK. Свое решение — это хорошо, но SDK это тот же самый код, который уже отлажен, упрощен и готов для использования. Несколько слов о том, как настроить и использовать Arduino IDE для работы с NodeMCU.
После установки Arduino IDE вам нужно зайти в меню File – Preferences
И добавить ссылку на дополнительный менеджер плат – ввести в поле Additional Boards Manager URLs адрес: http://arduino.esp8266.com/versions/2.4.0/package_esp8266com_index.json
Затем зайти в меню Tools – Board xxx – Boardx Manager и установить ESP8266
Устанавливаем библиотеки AzureIoTHub, AzureIoTUtility, AzureIoTProtocol_MQTT. После установки последней библиотеки в примерах (меню File – Examples — AzureIoTProtocol_MQTT) можно найти пример simplesample_mqtt для ESP8266.
Пример готов к работе. Достаточно только заполнить значения переменных в файле iot_configs.h
Упомяну об одном небольшом минусе. Компиляция проекта и загрузка на плату, по сравнению с Lua, занимает довольно долгое время.
Сохранение данных в облаке Azure
С отправкой данных все понятно, но как недорого сохранить данные в облаке.
Самым недорогим на сегодняшний момент способом отправить данные из IoT хаба в базу данных является все те же Azure Functions. А самым недорогим хранилищем данных являются Azure Table Storage.
Интересно, что при создании Function App автоматически создается и Storage, который необходим самой функции для работы. Если вы создаете отдельное хранилище, то основные настройки желательно сделать примерно такими:
LSR репликация на данный момент это самый недорогой вариант. Он выбирается при автоматическом создании хранилища, привязанного к функции.
Что нам нужно сейчас это получать данные от IoT хаба и записывать их в хранилище. Для этого случая окошко Quick Start при создании функции нам нужный вариант предложить не сможет
Поэтому нажимаем ссылку Custom function, расположенную внизу и выбираем вариант IoT Hub (Event Hub).
Здесь нам откроется такое окошко:
В котором поле Event Hub connection мы можем заполнить простым выбором (нажав new). А вот чтобы указать Event Hub name необходимо зайти в IoT хаб. В хабе нужно зайти в Endpoints (конечные точки) и взять оттуда Event Hub-compatible name
Перейдем к коду функции. Следующий сниппет получает данные из IoT хаба и сохраняет в Table Storage:
#r "Microsoft.WindowsAzure.Storage"
#r "Newtonsoft.Json"
using Microsoft.Azure; // Namespace for CloudConfigurationManager
using Microsoft.WindowsAzure.Storage; // Namespace for CloudStorageAccount
using Microsoft.WindowsAzure.Storage.Table; // Namespace for Table storage types
using Newtonsoft.Json;
public static void Run(string myIoTHubMessage, TraceWriter log)
{
var e = JsonConvert.DeserializeObject<EventDataEntity>(myIoTHubMessage);
log.Info($"C# IoT Hub trigger function processed a message: {myIoTHubMessage}");
CloudStorageAccount storageAccount = CloudStorageAccount.Parse ("DefaultEndpointsProtocol=https;AccountName=iotdatademostorage;AccountKey=JgStNcJvlQYeNsVCmpkHQUkWlZiQ7tJwAm6OCL34+lGx3XrR+0CPiY9RoxIDA6VSvMKlOEUrVWL+KWP0qLMLrw==;EndpointSuffix=core.windows.net");
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
CloudTable table = tableClient.GetTableReference("iottable");
table.CreateIfNotExists();
EventDataEntity edata = new EventDataEntity("IOTpartition", Guid.NewGuid().ToString());
edata.DeviceId = e.DeviceId;
edata.IotData = e.IotData;
TableOperation insertOperation = TableOperation.Insert(edata);
table.Execute(insertOperation);
}
public class EventDataEntity : TableEntity
{
public EventDataEntity(string pkey, string rkey)
{
this.PartitionKey = pkey;
this.RowKey = rkey;
}
public EventDataEntity() { }
public string DeviceId { get; set; }
public int IotData { get; set; }
}
Если вы будете использовать этот код в реальном проекте, то не забудьте вынести строку подключения в более безопасное место — в App settings (точно так же, как и строку подключения первой функции).
Саму строку подключения можно взять в пункте настроек под названием Access keys:
Просматривать содержимое таблиц можно с помощью бесплатной утилиты Azure Storage Explorer
Так как стоимость Azure Table Storage довольно низкая, а Azure Functions и IoT хаб предлагают определенные ресурсы ежемесячно бесплатно, то стоимость всего решения в месяц может составить меньше 1 доллара. Конечно, это зависит от количества данных. Считайте сами. На сегодняшний момент 1 Гб данных стоит 7 центов в месяц и за каждый миллион транзакций с вас возьмут всего 4 цента.
INFO
Всегда советую при использовании облачных сервисов от любого провайдера привязывать к аккаунту кредитную карту с минимально необходимой суммой денег. Вполне реально случайно выбрав какую-то ошибочную настройку заплатить гораздо больше, чем вы рассчитываете.
Напоминаем, что это полная версия статьи из журнала Хакер. Ее автор — Алексей Соммер.
Полезные материалы
Руководство по архитектуре облачных приложений
Используйте структурированный подход к разработке облачных приложений. В этой 300-страничной электронной книге об архитектуре облачных вычислений рассматриваются рекомендации по архитектуре, разработке и внедрению, которые применяются независимо от выбранной облачной платформы. В это руководство включены шаги по:
- выбору правильного стиля архитектуры облачного приложения для своего приложения или решения;
- выбору соответствующих технологий вычислений и хранения данных;
- внедрению 10 принципов разработки для создания масштабируемого, отказоустойчивого и управляемого приложения;
- следованию пяти принципам создания качественного программного обеспечения, гарантирующего успех вашего облачного приложения;
- использованию конструктивных шаблонов, предназначенных для проблемы, которую вы пытаетесь решить.
→ Скачать
Руководство разработчика по Azure
Из этого обновления руководства разработчика по Azure вы узнаете, насколько полный набор служб для программной платформы Azure соответствует вашим задачам. Здесь вы найдете сведения об архитектурных подходах и наиболее распространенных ситуациях, которые возникают при создании облачных приложений.
→ Скачать
Основы Microsoft Azure
Эта книга дает представление о важной информации о ключевых службах Azure для разработчиков и ИТ-специалистов, которые не знакомы с облачными вычислениями. Пошаговые демонстрации включены, чтобы помочь читателю понять, как начать работу с каждой из ключевых служб. Каждая глава является самостоятельной, не требуется выполнять практические демонстрации из предыдущих глав, чтобы понять какую-либо конкретную главу.
В этой книге рассматриваются следующие темы:
- Начало работы с Azure;
- Служба приложений Azure и веб-приложения;
- Виртуальные машины;
- Служба хранилища;
- Базы данных;
- Дополнительные службы Azure.
→ Скачать
Полезные ссылки
Автор: sahsAGU