[Из песочницы] SNMP + Java – невозможное возможно: пишем парсер MIB-файлов
SNMP — не самый юзер-френдли протокол: MIB-файлы слишком длинные и запутанные, а OID’ы просто невозможно запомнить. А что если возникла необходимость работать с SNMP на Java? Например, написать автотесты для проверки API SNMP-сервера.
Путём проб и ошибок при наличии довольно скудного количества информации по теме мы все же придумали, как подружить Java и SNMP.
В этой серии статей я постараюсь поделиться полученным опытом работы с протоколом. Первая статья в серии будет посвящена реализации парсера MIB-файлов на Java. Во второй части я расскажу о написании SNMP-клиента. В третьей части речь пойдёт о реальном примере использования написанной библиотеки: автотестах для проверки взаимодействия с устройством по протоколу SNMP.
Вступление
Всё началось с того, что поступила задача написать автотесты для проверки работы аудио-видео регистратора по протоколу SNMP. Осложняло ситуацию то, что информации по взаимодействию с SNMP на Java не так уж много, особенно если говорить о русскоязычном сегменте интернета. Конечно, можно было посмотреть в сторону C# или Python. Но в C# ситуация с протоколом примерно такая же сложная, как и в Java. В питоне же есть пара неплохих библиотек, но у нас уже была готовая инфраструктура для автотестов REST API данного устройства именно на Java.
Как и для любого другого протокола взаимодействия по сети, нам нужен был SNMP-клиент для работы с различными видами запросов. Автотесты должны были уметь проверять успешность GET- и SET-запросов для скалярных и табличных параметров. Для таблиц требовалось также иметь возможность проверить добавление и удаление записей, если сама таблица допускает эти операции.
Но помимо клиента библиотека должна была содержать класс для работы с MIB-файлами. Этот класс должен был уметь распарсить MIB-файл для получения типов данных, границ допустимых значений и т.д., чтобы не хардкодить то, что всегда может измениться.
В ходе поиска подходящих библиотек для Java мы не нашли ни одной библиотеки, которая позволяла бы работать и с запросами, и с MIB-файлами. Поэтому мы остановились на двух разных библиотеках. Для клиента вполне логичным показался выбор широко используемой org.snmp4j.snmp4j (https://www.snmp4j.org), а для парсера MIB-файлов выбор пал на не особо известную библиотеку net.percederberg.mibble (https://www.mibble.org). Если с snmp4j выбор был очевиден, то mibble была выбрана за наличие достаточно подробной (хоть и англоязычной) документации с примерами. Итак, начнём.
Пишем парсер MIB-файлов
Все, кто когда-либо видел MIB-файлы, знают, что это боль. Попробуем это исправить с помощью несложного парсера, который значительно облегчит поиск информации из файла, сведя его к вызову того или иного метода.
Такой парсер можно использовать как отдельную утилиту для работы с MIB-файлами или включить в любой другой проект под SNMP, например, при написании SNMP-клиента или автоматизации тестирования.
Подготовка проекта
Для удобства сборки мы используем Maven. В зависимости добавляем библиотеку net.percederberg.mibble (https://www.mibble.org), которая облегчит нам работу с MIB-файлами:
net.percederberg.mibble
mibble
2.9.3
Так как она лежит не в центральном репозитории Maven, добавляем в pom.xml следующий код:
opennms
OpenNMS
http://repo.opennms.org/maven2/
Если проект собирается с помощью мавена без ошибок, всё готово для работы. Осталось только создать класс парсера (назовём его MIBParser) и импортировать всё, что нам нужно, а именно:
import net.percederberg.mibble.*;
Загрузка и валидация MIB-файла
Внутри класса будет только одно поле — объект типа net.percederberg.mibble.Mib для хранения загруженного MIB-файла:
private Mib mib;
Для загрузки файла пишем вот такой метод:
private Mib loadMib(File file) throws MibLoaderException, IOException {
MibLoader loader = new MibLoader();
Mib mib;
file = file.getAbsoluteFile();
try {
loader.addDir(file.getParentFile());
mib = loader.load(file);
} catch (MibLoaderException e) {
e.getLog().printTo(System.err);
throw e;
} catch (IOException e) {
e.printStackTrace();
throw e;
}
return mib;
}
Класс net.percederberg.mibble.MIBLoader валидирует файл, который мы пытаемся загрузить и бросит исключение net.percederberg.mibble.MibLoaderException в случае, если найдёт в нём какие-то ошибки, в том числе ошибки импорта из других MIB-файлов, если они не лежат в той же директории или не содержат импортируемые MIB-символы.
В методе loadMib ловим все исключения, пишем о них в лог и прокидываем дальше, т.к. на этом этапе продолжение работы невозможно — файл не валидный.
Написанный метод мы вызываем в конструкторе парсера:
public MIBParser(File file) throws MibLoaderException, IOException {
if (!file.exists())
throw new FileNotFoundException("File not found in location: " + file.getAbsolutePath());
mib = loadMib(file.getAbsoluteFile());
if (!mib.isLoaded())
throw new MibLoaderException(file, "Not loaded.");
}
Если файл успешно загрузился и распарсился, продолжаем работу.
Методы для получения информации из MIB-файла
С помощью методов класса net.percederberg.mibble.Mib можно искать отдельные символы MIB-файла по имени или OID с помощью вызова метода getSymbol (String name) или getSymbolByOid (String oid) соответственно. Эти методы возвращают нам объект net.percederberg.mibble.MibSymbol, методами которого мы будем пользоваться для получения необходимой информации по конкретному MIB-символу.
Начнём с самого простого и напишем методы для получения имени символа по его OID и, наоборот, OID по имени:
public String getName(String oid) {
return mib.getSymbolByOid(oid).getName();
}
public String getOid(String name) {
String oid = null;
MibSymbol s = mib.getSymbol(name);
if (s instanceof MibValueSymbol) {
oid = ((MibValueSymbol) s).getValue().toString();
if (((MibValueSymbol) s).isScalar())
oid = new OID(oid).append(0).toDottedString();
}
return oid;
}
Возможно, это особенности конкретного MIB-файла, с которым мне было необходимо работать, но по каким-то причинам для скалярных параметров возвращался OID без нуля на конце, поэтому в метод получения OID«а был добавлен код, который в случае, если MIB-символ скалярный, просто добавляет к полученному OID ».0» с помощью метода append (int index) из класса net.percederberg.mibble.OID. Если у вас работает и без костыля, поздравляю :)
Для получения остальных данных по символу пишем один вспомогательный метод, где получаем объект net.percederberg.mibble.snmp.SnmpObjectType, содержащий в себе всю необходимую информацию о том MIB-символе, из которого он получен.
private SnmpObjectType getSnmpObjectType(MibSymbol symbol) {
if (symbol instanceof MibValueSymbol) {
MibType type = ((MibValueSymbol) symbol).getType();
if (type instanceof SnmpObjectType) {
return (SnmpObjectType) type;
}
}
return null;
}
Например, мы можем получить тип MIB-символа:
public String getType(String name) {
MibSymbol s = mib.getSymbol(name);
if (getSnmpObjectType(s).getSyntax().getReferenceSymbol() == null)
return getSnmpObjectType(s).getSyntax().getName();
else
return getSnmpObjectType(s).getSyntax().getReferenceSymbol().getName();
}
Здесь есть предусмотрено 2 способа получения типа, т.к. для примитивных типов работает первый вариант:
getSnmpObjectType(s).getSyntax().getName();
а для импортированных — второй:
getSnmpObjectType(s).getSyntax().getReferenceSymbol().getName();
Можно получить уровень доступа к символу:
public String getAccess(String name) {
MibSymbol s = mib.getSymbol(name);
return getSnmpObjectType(s).getAccess().toString();
}
Минимальное допустимое значение числового параметра:
public Integer getDigitMinValue(String name) {
MibSymbol s = mib.getSymbol(name);
String syntax = getSnmpObjectType(s).getSyntax().toString();
if (syntax.contains("STRING"))
return null;
Pattern p = Pattern.compile("(-?\\d+)..(-?\\d+)");
Matcher m = p.matcher(syntax);
if (m.find()) {
return Integer.parseInt(m.group(1));
}
return null;
}
Максимальное допустимое значение числового параметра:
public Integer getDigitMaxValue(String name) {
MibSymbol s = mib.getSymbol(name);
String syntax = getSnmpObjectType(s).getSyntax().toString();
if (syntax.contains("STRING"))
return null;
Pattern p = Pattern.compile("(-?\\d+)..(-?\\d+)");
Matcher m = p.matcher(syntax);
if (m.find()) {
return Integer.parseInt(m.group(2));
}
return null;
}
Минимальную допустимую длину строки (зависит от типа строки):
public Integer getStringMinLength(String name) {
MibSymbol s = this.mib.getSymbol(name);
String syntax = this.getSnmpObjectType(s).getSyntax().toString();
Pattern p = Pattern.compile("(-?\\d+)..(-?\\d+)");
Matcher m = p.matcher(syntax);
return syntax.contains("STRING") && m.find()?Integer.valueOf(Integer.parseInt(m.group(1))):null;
}
Максимальную допустимую длину строки (зависит от типа строки):
public Integer getStringMaxLength(String name) {
MibSymbol s = mib.getSymbol(name);
String syntax = getSnmpObjectType(s).getSyntax().toString();
Pattern p = Pattern.compile("(-?\\d+)..(-?\\d+)");
Matcher m = p.matcher(syntax);
if (syntax.contains("STRING") && m.find()) {
return Integer.parseInt(m.group(2));
}
return null;
}
Также можно получить имена всех колонок в таблице по её имени:
public ArrayList getTableColumnNames(String tableName) {
ArrayList mibSymbolNamesList = new ArrayList<>();
MibValueSymbol table = (MibValueSymbol) mib.findSymbol(tableName, true);
if (table.isTable() && table.getChild(0).isTableRow()) {
MibValueSymbol[] symbols = table.getChild(0).getChildren();
for (MibValueSymbol mvs : symbols) {
mibSymbolNamesList.add(mvs.getName());
}
}
return mibSymbolNamesList;
}
Сначала стандартным образом получаем MIB-символ по имени:
MibValueSymbol table = (MibValueSymbol) mib.findSymbol(tableName, true);
Затем проверяем, является ли он таблицей и что дочерний MIB-символ — строка этой таблицы и, если условие возвращает true, в цикле идём по дочерним элементам табличной строки и добавляем имя элемента в результирующий массив:
if (table.isTable() && table.getChild(0).isTableRow()) {
MibValueSymbol[] symbols = table.getChild(0).getChildren();
for (MibValueSymbol mvs : symbols) {
mibSymbolNamesList.add(mvs.getName());
}
}
Итоги
Этих методов хватает для получения любой информации из MIB-файла по каждому конкретному символу, зная только его имя. Например, при написании SNMP-клиента можно включить в него такой парсер, чтобы методы клиента на вход принимали не OID«ы, а имена MIB-символов. Это повысит надёжность кода, т.к. опечатка в OID«е может привести не к тому символу, к которому мы хотим обратиться. А нет OID«ов — нет и проблем.
В плюс идёт читаемость кода, а значит и его поддерживаемость. Проще вникнуть в суть проекта, если код оперирует человеческими названиями.
Ещё одно применение — это автоматизации тестирования. В тестовых данных можно получать граничные значения числовых параметров динамически из MIB-файла. Так, если изменятся граничные значения у каких-то MIB-символов в новой версии тестируемого компонента, не придётся менять код автотестов.
В целом, при помощи парсера работать с MIB-файлами становится намного приятнее, и они перестают быть такой уж болью.