Создаем почтовый клиент на Ext JS с нуля. Часть 6: Интеграция с REST API

В первых пяти частях мы построили полноценный почтовый клиент Ext Mail: создали Universal-приложение, модели и хранилища, Grid и Reader, дерево меток, динамический тулбар, форму создания письма и работу с черновиками. Приложение работало и на десктопе (Classic Toolkit), и на мобильных устройствах (Modern Toolkit).

Однако у нашего приложения был один существенный недостаток: данные загружались из статических JSON-файлов и никогда не сохранялись. При обновлении страницы все изменения — отправленные письма, удалённые черновики, новые метки — исчезали.

В шестой части мы исправим это: подключим приложение к настоящему REST API (созданному с помощью RAD Server) и настроим загрузку и сохранение данных. Это последний шаг перед тем, как приложение станет полностью production-ready.

Все примеры кода взяты из оригинальной книги Stuart Ashworth (Sencha MVP) и адаптированы для русскоязычного читателя.

🎯 Что мы сделаем

  1. Создадим утилитарный класс BaseUrl для централизованного управления адресом API.

  2. Переключим Contacts Store на REST-эндпоинт.

  3. Настроим Labels Store для загрузки дерева меток с API.

  4. Проведём рефакторинг модели Message — добавим связь hasMany для меток.

  5. Подключим Messages Store к API с полной поддержкой CRUD.

  6. Настроим сохранение изменений с буферизацией запросов.

🌐 Создаём утилитарный класс BaseUrl

Прежде чем менять хранилища, нужно решить одну проблему: все AJAX-запросы должны идти на наш API, а не на статические файлы. Можно было бы прописать полный URL в каждом хранилище, но это привело бы к дублированию и усложнило бы переключение между окружениями (разработка, тестирование, продакшен).

Решение — создать singleton-класс, который будет перехватывать все AJAX-запросы и добавлять к ним базовый URL API.

Создаём класс ExtMail.util.BaseUrl в общей папке app/util/:

javascript
Ext.define('ExtMail.util.BaseUrl', {
    singleton: true,
    requires: ['Ext.Ajax'],

    config: {
        baseUrl: ''
    },

    constructor: function(config) {
        this.initConfig(config);
        Ext.Ajax.on('beforerequest', this.onBeforeAjaxRequest, this);
    },

    onBeforeAjaxRequest: function(connection, options) {
        options.url = this.getBaseUrl() + options.url;
    }
});

Мы подписываемся на глобальное событие beforerequest класса Ext.Ajax. Оно срабатывает перед каждым AJAX-запросом, и мы просто добавляем к URL базовый адрес API.

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

Класс автоматически загрузится благодаря wildcard-зависимости ExtMail.* в app.js. Осталось задать базовый URL при запуске приложения в Application.js:

javascript
onBeforeLaunch: function() {
    ExtMail.util.BaseUrl.setBaseUrl('https://your-api-server.com/');
    this.callParent(arguments);
}

В продакшене этот URL обычно подставляется из конфигурации окружения на этапе сборки.

📇 Подключаем Contacts Store

Начнём с самого простого хранилища — контактов. Оно загружается при старте приложения и не меняется в течение жизненного цикла.

Меняем прокси с ajax на rest:

javascript
proxy: {
    type: 'rest',
    url: 'data/contacts/',
    reader: {
        type: 'json',
        transform: function(data) {
            return Ext.Array.map(data.result, ExtMail.util.Object.snakeCaseToCamelCase);
        }
    }
}

Ключевые изменения:

  • Тип прокси — rest вместо ajax. Это значит, что запросы будут использовать правильные HTTP-глаголы (GET, POST, PUT, DELETE) и следовать REST-паттерну.

  • URL — теперь указывает на эндпоинт API, а не на статический файл. Базовый URL добавится автоматически через наш BaseUrl.

  • Transform — функция, которая вызывается после загрузки данных и позволяет преобразовать структуру перед тем, как она попадёт в хранилище.

В нашем случае API возвращает данные в SNAKE_CASE, а приложение ожидает camelCase. Утилитарный класс ExtMail.util.Object конвертирует ключи объекта из одного формата в другой.

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

🌳 Загружаем Labels Store

Дерево меток устроено сложнее — это иерархическая структура с вложенными узлами. Поэтому transform-функция должна рекурсивно обрабатывать каждый узел.

javascript
proxy: {
    type: 'rest',
    url: 'data/labelsnested',
    reader: {
        type: 'json',
        transform: function(data) {
            var transformRow = function(row) {
                row = ExtMail.util.Object.snakeCaseToCamelCase(row);
                if (row.children && Ext.isArray(row.children)) {
                    row.children = Ext.Array.map(row.children, transformRow);
                }
                return row;
            };
            return Ext.Array.map(data, transformRow);
        }
    }
}

Мы определяем внутреннюю функцию transformRow, которая:

  1. Конвертирует ключи текущего узла в camelCase.

  2. Если у узла есть дочерние элементы (children), рекурсивно применяет себя к каждому из них.

  3. Возвращает преобразованный узел.

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

🔗 Рефакторинг меток сообщений

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

Обновляем модель Message

Сначала меняем схему генерации ID на sequential — API использует числовые идентификаторы:

javascript
Ext.define('ExtMail.model.Message', {
    extend: 'Ext.data.Model',
    requires: [
        'Ext.data.identifier.Sequential',
        'ExtMail.store.MessageLabels'
    ],
    identifier: 'sequential',
    // ...
});

Затем убираем поле labels из сохраняемых на сервер — теперь метки будут управляться отдельной моделью:

javascript
{
    name: 'labels',
    type: 'auto',
    defaultValue: [],
    persist: false
}

И добавляем связь hasMany:

javascript
hasMany: [{
    model: 'ExtMail.model.MessageLabel',
    name: 'labels',
    storeConfig: {
        type: 'MessageLabels'
    }
}]

Связь hasMany создаёт вложенное хранилище (sub-store) внутри каждой модели Message. Оно будет содержать экземпляры модели MessageLabel — промежуточной сущности, связывающей письмо и метку.

Создаём MessageLabel Model и Store

Модель MessageLabel содержит всего два поля:

javascript
Ext.define('ExtMail.model.MessageLabel', {
    extend: 'Ext.data.Model',
    fields: [
        'messageId',
        'labelId'
    ],
    // ...
});

Прокси для этой модели настраивается нестандартно — URL формируется динамически из двух полей:

javascript
proxy: {
    type: 'ajax',
    noCache: false,
    url: 'labels/',
    actionMethods: {
        create: 'POST',
        destroy: 'DELETE'
    },
    writer: {
        type: 'json',
        writeAllFields: true,
        transform: function(data) {
            if (data) {
                data = ExtMail.util.Object.camelCaseToSnakeCase(data);
            }
            return data;
        }
    }
}

Мы используем ajax-прокси, а не rest, потому что нам нужно вручную собрать URL вида labels/<messageId>/<labelId>. Делаем это в конструкторе модели:

javascript
constructor: function() {
    this.callParent(arguments);
    this.getProxy().setUrl(
        Ext.String.format(
            'labels/{0}/{1}',
            this.get('messageId'),
            this.get('labelId')
        )
    );
}

Хранилище MessageLabels настраивается просто:

javascript
Ext.define('ExtMail.store.MessageLabels', {
    extend: 'Ext.data.Store',
    alias: 'store.MessageLabels',
    model: 'ExtMail.model.MessageLabel',
    autoSync: true
});

Ключевой параметр — autoSync: true. Это значит, что при добавлении или удалении записи хранилище автоматически отправит запрос на сервер. Нам не нужно вызывать sync() вручную.

Обновляем вспомогательные методы модели Message

Три метода работы с метками — hasLabel, addLabel, removeLabel — нужно переписать, чтобы они работали с новой связью.

hasLabel — использует findExact вложенного хранилища:

javascript
hasLabel: function(labelId) {
    return this.labels().findExact('labelId', labelId) >= 0;
}

addLabel — добавляет запись во вложенное хранилище:

javascript
addLabel: function(labelId) {
    var labels = this.get('labels') || [];
    labels.push(labelId);
    this.set('labels', Ext.clone(labels));

    this.labels().add({
        messageId: this.getId(),
        labelId: labelId
    });
}

removeLabel — находит и удаляет запись:

javascript
removeLabel: function(labelId) {
    var labels = this.get('labels') || [];
    labels = Ext.Array.remove(labels, labelId);
    this.set('labels', Ext.clone(labels));

    var index = this.labels().findExact('labelId', labelId);
    this.labels().removeAt(index);
}

Во всех трёх методах мы по-прежнему обновляем массив labels в самой модели — это нужно для UI, чтобы компоненты, привязанные к этому полю, обновились. А операции с вложенным хранилищем обеспечивают синхронизацию с API.

📬 Подключаем Messages Store

Теперь настроим главное хранилище — Messages. Здесь мы используем конфигурацию api, чтобы указать разные эндпоинты для разных операций:

javascript
proxy: {
    type: 'rest',
    noCache: false,
    api: {
        read: 'data/messagesfull',
        create: 'data/messages/',
        update: 'data/messages/',
        destroy: 'data/messages/'
    },
    reader: {
        type: 'json',
        transform: function(data) {
            if (data) {
                if (Ext.isArray(data)) {
                    data = Ext.Array.map(data, ExtMail.util.Object.snakeCaseToCamelCase);

                    Ext.each(data, function(row) {
                        row.labels = Ext.Array.map(
                            row.labels || [],
                            function(labelId) {
                                return {
                                    messageId: row.id,
                                    labelId: labelId
                                };
                            }
                        );
                    });
                } else {
                    data = ExtMail.util.Object.snakeCaseToCamelCase(data);
                }
            }
            return data;
        }
    }
}

Здесь transform-функция делает две вещи:

  1. Конвертирует ключи из SNAKE_CASE в camelCase.

  2. Преобразует массив ID меток в массив объектов { messageId, labelId }, которые затем станут экземплярами модели MessageLabel во вложенном хранилище.

В большинстве случаев API уже возвращает данные в правильной структуре, и этот шаг не нужен.

💾 Сохраняем изменения сообщений

Последний шаг — настроить writer для сохранения изменений в модели Message:

javascript
writer: {
    type: 'json',
    writeAllFields: true,
    transform: function(data) {
        if (data) {
            data = ExtMail.util.Object.camelCaseToSnakeCase(data);
            delete data.ID;
            data.DATE = Ext.Date.format(
                Ext.Date.parse(data.DATE, 'c'),
                'm/d/Y H:i:s'
            );
        }
        return data;
    }
}

Writer выполняет три преобразования:

  1. Конвертирует ключи из camelCase в SNAKE_CASE.

  2. Удаляет поле ID — оно уже включено в URL.

  3. Переформатирует поле DATE из ISO-формата в формат, который ожидает API (m/d/Y H:i:s).

Буферизация запросов

Если оставить всё как есть, каждая нажатая клавиша в поле сообщения будет вызывать sync() и отправлять запрос на сервер. Это создаст огромную нагрузку.

Решение — буферизация. Переопределяем метод sync в конструкторе хранилища, оборачивая его в Ext.Function.createBuffered:

javascript
constructor: function() {
    this.callParent(arguments);
    this.sync = Ext.Function.createBuffered(this.sync, 500, this);
}

Теперь повторные вызовы sync() в течение 500 миллисекунд будут игнорироваться, и запрос отправится только один раз — после того, как пользователь перестанет печатать.

💎 Заключение

В шестой части мы успешно перевели приложение с статических JSON-файлов на полноценный REST API:

  • Создали утилитарный класс BaseUrl для централизованного управления адресом API.

  • Переключили Contacts Store и Labels Store на REST-эндпоинты.

  • Провели рефакторинг модели Message: добавили связь hasMany с моделью MessageLabel.

  • Создали MessageLabel Model и MessageLabels Store с автоматической синхронизацией.

  • Подключили Messages Store к API с поддержкой CRUD-операций.

  • Настроили сохранение изменений с буферизацией запросов.

Теперь состояние приложения сохраняется на сервере и не теряется при обновлении страницы. В заключительной, седьмой части мы добавим deep linking и Router — механизм, который позволит пользователям делиться ссылками на конкретные письма и метки, а также использовать кнопки «Назад»/«Вперёд» в браузере.

Оставьте комментарий