Как стать автором
Обновить

Кастомные QSettings::ReadFunc и QSettings::WriteFunc, или как я написал костыль для русификации файла настроек

Время на прочтение9 мин
Количество просмотров4.2K

Введение


Привет, Хабр!

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

Проблема


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

Так как параметры разделены на группы по модулям устройства, то в конечном варианте получается структура «Группа — Ключ — Значение». Подходящим (но предназначенным по задумке для данной задачи) стал QSettings. Первая проба «пера» дала фиаско, с которым я не ожидал столкнуться.

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

    // Файл настроек (будет сохранен в директорию: 
    // C:\Users\USER_NAME\AppData\Roaming\Организация)
    QSettings parameters(QSettings::IniFormat, QSettings::UserScope,
                         QString("Организация"), QString("Приложение"));

    // Группа
    const QString group = QString("Основные параметры");
    const QString key = QString("Параметр №1");
    const QString value = QString("Значение №1");

    // Запись группа - ключ - значение
    parameters.beginGroup(group);
    parameters.setValue(key, value);
    parameters.endGroup();

    // Сохранение записи
    parameters.sync();

Какое содержимое файла я хотел увидеть:

[Основные параметры]
Параметр №1=Значение №1

и что содержал Приложение.ini:

[%U041E%U0441%U043D%U043E%U0432%U043D%U044B%U0435%20%U043F%U0430%U0440%U0430%U043C%U0435%U0442%U0440%U044B]
%U041F%U0430%U0440%U0430%U043C%U0435%U0442%U0440%20%U21161=\x417\x43d\x430\x447\x435\x43d\x438\x435 \x2116\x31

При этом, что интересно. Если проделать обратную процедуру чтения, то при выводе значения можно увидеть, что оно считано корректно.

    // ... Открытие файла настроек
    
    // Группа
    const QString group = QString("Основные параметры");
    const QString key = QString("Параметр №1");
    const QString value = QString("Значение №1");

    // Чтение группа - ключ - значение
    parameters.beginGroup(group);
    QString fileValue = parameters.value(key).toString();
    parameters.endGroup();

    // Вывод значения в консоль
    qDebug() << value << fileValue << (value == fileValue);

Вывод в консоль:

"Значение №1" "Значение №1" true

«Старое» решение


Отправился гуглить (в яндекс). Ясно, что проблема с кодировками, но зачем разбираться самому, когда через минуту ты уже можешь узнать ответ:) Удивило, что не было очевидно написанных решений (жми сюда, пропиши вот это, живи и радуйся).

Один из немногих топиков с заголовком [РЕШЕНО]: www.prog.org.ru/topic_15983_0.html. Но, как оказалось в ходе прочтения треда, в Qt4 можно было решить вопрос с кодировками, а в Qt5 уже нет: www.prog.org.ru/index.php?topic=15983.msg182962#msg182962.

Добавив в начало «примерочного» кода строки с решением из форума (под капотом спрятаны «игры» со всеми из возможных кодировок и функциями классов Qt, связанные с ними), понял, что это только частично решает проблему.

    // Кодировка
    QTextCodec *codec = QTextCodec::codecForName("UTF-8");
    QTextCodec::setCodecForLocale(codec);
    // Не работает в Qt5
    // QTextCodec::setCodecForTr(codec);
    // QTextCodec::setCodecForCStrings(codec);

    // Файл настроек (будет сохранен в директорию:
    // C:\Users\USER_NAME\AppData\Roaming\Организация)
    QSettings parameters(QSettings::IniFormat, QSettings::UserScope,
                         QString("Организация"), QString("Приложение"));
    parameters.setIniCodec(codec);

    // ... Запись и чтение

Небольшое изменение в Приложение.ini (теперь значение параметра сохранено в кириллице):

[%U041E%U0441%U043D%U043E%U0432%U043D%U044B%U0435%20%U043F%U0430%U0440%U0430%U043C%U0435%U0442%U0440%U044B]
%U041F%U0430%U0440%U0430%U043C%U0435%U0442%U0440%20%U21161=Значение №1

Костыль


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

Как выяснилось из официальной документации doc.qt.io/qt-5/qsettings.html можно зарегистрировать свой формат для хранения данных: doc.qt.io/qt-5/qsettings.html#registerFormat. Все, что требуется — это выбрать расширение файла (пусть это будет "*.habr"), где будут храниться данные, и написать указанные выше функции.

Теперь «начинка» main.cpp выглядит следующим образом:

bool readParameters(QIODevice &device, QSettings::SettingsMap &map);
bool writeParameters(QIODevice &device, const QSettings::SettingsMap &map);

int main(int argc, char *argv[])
{
    // Собственный формат
    const QSettings::Format habrFormat = QSettings::registerFormat(
                "habr", readParameters, writeParameters, Qt::CaseSensitive);
    if (habrFormat == QSettings::InvalidFormat) {
        qCritical() << "Ошибка создания хабр-формата";
        return 0;
    }

    // Файл настроек (будет сохранен в директорию:
    // C:\Users\USER_NAME\AppData\Roaming\Организация)
    QSettings *parameters = new QSettings(habrFormat, QSettings::UserScope,
                                          QString("Организация"), QString("Приложение"));

    // ... Запись и чтение

    return 0;
}

Начнем с написания функции записи данных в файл (сохранять данные ведь легче, чем их парсить). В документации doc.qt.io/qt-5/qsettings.html#WriteFunc-typedef сказано, что функция записывает набор пар ключ/значение. Вызывается она однократно, поэтому сохранить данные нужно за один раз. Параметры функции — QIODevice &device (ссылка на «I/O устройство») и QSettings::SettingsMap (контейнер QMap<QString, QVariant>).

Так как в контейнере название ключа хранится в виде «Группа/параметр» (интерпретируя к своей задаче), то предварительно необходимо разделить названия группы и параметра. Затем, если началась следующая группа параметров, то необходимо вставить разделитель в виде пустой строки.

// Функция записи параметров в файл
bool writeParameters(QIODevice &device, const QSettings::SettingsMap &map)
{
    // Проверка, что устройство открыто
    if (device.isOpen() == false) {
        return false;
    }

    // Переменная необходима, чтобы отделить группы
    QString lastGroup;

    // Воспользуемся текстовым потоком записи данных в файл
    QTextStream outStream(&device);

    // Проходим по каждому параметру
    // (в контейнере они выставлены по алфавитному порядку)
    for (const QString &key : map.keys()) {
        // Разделяем группу и название параметра по символу "/"
        int index = key.indexOf("/");
        if (index == -1) {
            // Сюда можно вставить код записи параметров
            // без группы (например, в дефолтную "Другие")
            continue;
        }

        // Если группа отличается от предыдущей, то
        // вставляется разделитель и начинается новая группа
        QString group = key.mid(0, index);
        if (group != lastGroup) {
            // Пустая строка (разделитель) между группами. Исключается
            // ввод разделителя между началом файла и первой группой
            if (lastGroup.isEmpty() == false) {
                outStream << endl;
            }
            outStream << QString("[%1]").arg(group) << endl;
            lastGroup = group;
        }

        // Запись параметра и значения
        QString parameter = key.mid(index + 1);
        QString value = map.value(key).toString();
        outStream << QString("%1=%2").arg(parameter).arg(value) << endl;
    }

    return true;
}

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

    // Собственный формат
    const QSettings::Format habrFormat = QSettings::registerFormat(
                "habr", QSettings::ReadFunc(), writeParameters, Qt::CaseSensitive);

    // ... Последующий код

Данные в файле:

[Основные параметры]
Параметр №1=Значение №1

Вывод в консоль:

"Значение №1" "Значение №1" true

На этом можно было бы закончить. QSettings выполняет свои функции по считыванию всех ключей, их хранения в файле. Только есть нюанс, что если записать параметр без группы, то QSettings будет хранить его в своей памяти, но не сохранит его в файл (надо дополнить код в функции readParameters в месте, где не найден разделитель "/" в названии ключа у контейнера const QSettings::SettingsMap &map).

Я предпочел написать свою функцию парсинга данных из файла, чтобы можно было гибко управлять видом хранения данных (например, названия групп обрамлены не квадратными скобками, а другими распознавательными символами). Другая причина — показать, как все работает при наличии обоих кастомных функций чтения и записи.

В документации doc.qt.io/qt-5/qsettings.html#ReadFunc-typedef сказано, что функция считывает набор пар ключ/значение. Она должна считывать все данные за один проход и возвращать все данные в контейнер, который указан как параметр функции, и изначально он пустой.

// Функция чтения параметров из файла
bool readParameters(QIODevice &device, QSettings::SettingsMap &map)
{
    // Проверка, что устройство открыто
    if (device.isOpen() == false) {
        return false;
    }

    // Воспользуемся текстовым потоком чтения данных из файла
    QTextStream inStream(&device);

    // Текущая группа
    QString group;

    // Будем парсить каждую строку
    while (inStream.atEnd() == false) {
        // Строка
        QString line = inStream.readLine();

        // Если в данный момент не задана группа
        if (group.isEmpty()) {
            // Название группы заключено в квадратные скобки
            if (line.front() == '[' && line.back() == ']') {
                // Убираем символы скобок
                group = line.mid(1, line.size() - 2);
            }
            // Игнорируем строку, если нет группы
            // Переход к следующей строке
        }
        else {
            // Окончание группы, если строка пустая
            if (line.isEmpty()) {
                group.clear();
            }
            // Иначе строка с параметром
            else {
                // Параметр: Название=Значение
                int index = line.indexOf("=");
                if (index != -1) {
                    QString name = group + "/" + line.mid(0, index);;
                    QVariant value = QVariant(line.mid(index + 1));
                    // Вставляем в контейнер
                    map.insert(name, value);
                }
            }
        }
    }

    return true;
}

Возвращаем кастомную функцию чтения в инициализацию формата для QSettings и проверяем, что все работает:

    // Собственный формат
    const QSettings::Format habrFormat = QSettings::registerFormat(
                "habr", readParameters, writeParameters, Qt::CaseSensitive);

    // ... Последующий код

Вывод в консоль:

"Значение №1" "Значение №1" true

Работа костыля


Так как реализацию функций я «затачивал» под свою задачу, то нужно показать, как пользоваться получившимся «отпрыском». Как я говорил раньше, если попытаться записать параметр без группы, то QSettings сохранит его у себя в памяти и будет выводить при вызове метода allKeys().

    // Собственный формат
    const QSettings::Format habrFormat = QSettings::registerFormat(
                "habr", readParameters, writeParameters, Qt::CaseSensitive);
    if (habrFormat == QSettings::InvalidFormat) {
        qCritical() << "Ошибка создания хабр-формата";
        return 0;
    }

    // Файл настроек (будет сохранен в директорию:
    // C:\Users\USER_NAME\AppData\Roaming\Организация)
    QSettings *parameters = new QSettings(habrFormat, QSettings::UserScope,
                                          QString("Организация"), QString("Приложение"));

    // Первая группа
    const QString firstGroup = "Первая группа";
    parameters->beginGroup(firstGroup);
    parameters->setValue("Параметр №1", "Значение №1");
    parameters->setValue("Параметр №2", "Значение №2");
    parameters->endGroup();

    // Вторая группа
    const QString secondGroup = "Вторая группа";
    parameters->beginGroup(secondGroup);
    parameters->setValue("Параметр №3", "Значение №3");
    parameters->endGroup();

    // Параметр без группы
    parameters->setValue("Параметр №4", "Значение №4");

    // Запись в файл
    parameters->sync();

    qDebug() << parameters->allKeys();
    delete parameters;

    // Получение данных из файла
    parameters = new QSettings(habrFormat, QSettings::UserScope,
                               QString("Организация"), QString("Приложение"));

    qDebug() << parameters->allKeys();
    delete parameters;

Вывод в консоль («Параметр №4» здесь явно лишний):

("Вторая группа/Параметр №3", "Параметр №4", "Первая группа/Параметр №1", "Первая группа/Параметр №2")
("Вторая группа/Параметр №3", "Параметр №4", "Первая группа/Параметр №1", "Первая группа/Параметр №2")

При этом содержимое файла:

[Вторая группа]
Параметр №3=Значение №3

[Первая группа]
Параметр №1=Значение №1
Параметр №2=Значение №2

Решение для проблемы «ключей-одиночек» — контролировать процедуру записи данных при использовании QSettings. Не допускать сохранение параметров без начала и окончания группы или фильтровать ключи, которые не содержат в своем названии наименовании группы.

Заключение


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

После проделанной работы кажется, что вполне можно было бы написать обертку для QFile и жить счастливо. Но с другой стороны, помимо тех же функций чтения и записи пришлось бы писать дополнительный функционал, который уже есть у QSettings (получение всех ключей, работа с группой, запись несохраненных данных и прочий функционал, который не фигурировал в статье).

В чем польза? Может тем, кто столкнулся с аналогичной проблемой, или кому не сразу понятно, как реализовать и интегрировать свои функции чтения и записи, статья покажется полезной. В любом случае, будет приятно прочитать ваши мысли в комментариях.
Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии8

Публикации

Истории

Работа

Программист C++
137 вакансий
QT разработчик
9 вакансий

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург