Интеграция приложения на QML с веб-ресурсами

в 23:24, , рубрики: QML, qt, qt quick, Qt Software, qt5, xmlhttprequest, Вконтакте API, Программирование, метки: , , , , ,

Доброго времени суток, дорогой читатель! Я хочу рассказать, как интегрировать программу на новомодном языке QML с веб-ресурсами.

Сам по себе, QML — это декларативный JavaScript-подобный язык программирования, который входит в фреймворк Qt. Разработчики Qt настроены серьезно и продвигают его как основной инструмент создания интерфейсов. Более того, достаточно много вещей можно сделать не прибегая вообще к C++, в том числе и возможность работы с веб-серверами.

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

Учитывая, что в Qt 5.1, альфа версия которой вышла на этой неделе, включена начальная поддержка Android и iOS, эта тема может быть особенно интересна тем, кто присматривается к Qt или активно ее осваивает. В этой статье я расскажу, как можно организовать работу с веб-ресурсами из приложения на QML на примере API ВКонтакте.

На всякий случай отмечу, что я рассматриваю последнюю стабильную версию Qt 5.0.2. В более ранних версиях каких-то возможностей может не быть.

Что такое XMLHttpRequest и зачем он нужен

Наверняка, многие из читателей слышали про такую технологию, как AJAX (Asynchronous JavaScript And XML). Она позволяет отправлять асинхронные запросы на сервер и обновлять содержимое страницы без ее перезагрузки. В современных браузерах есть различные средства для этого, XMLHttpRequest является одним из них. Поскольку QML является JavaScript-подобным языком и JavaScript окружение в нем похоже на браузерное, то и XMLHttpRequest тоже присутствует. Далее в тексте я буду также записывать его название в сокращенной форме — XHR.

Собственно, что это такое и что оно нам дает? Это инструмент для асинхронных (в браузерах поддерживаются также и синхронные) HTTP-запросов. Несмотря на свое название, он позволяет передавать данные не только в формате XML, хотя изначально был предназначен именно для этого. Реализация в движке QML поддерживает следующие HTTP-запросы: GET, POST, HEAD, PUT и DELETE. В основном, мы будем пользоваться первыми двумя.

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

Процедура работы с XMLHttpRequest

Процесс работы с XHR выглядит следующим образом.

1. Создаем объект XHR:

var request = new XMLHttpRequest()

2. Инициализируем объект, указывая тип запроса (он же HTTP-метод), адрес и, если нужно, параметры запроса [1], которые нужно передать серверу:

request.open('GET', 'http://site.com?param1=value1&param2=value2')

Первым параметром передаем тип запроса, вторым — URL. Для GET-запроса параметры нужно передать здесь же, отделив их от адреса символом '?'. Параметры разделяются символом '&'.

Для POST-запроса нужно указать тип содержимого. Если мы передаем данные параметрами запроса, то делается это следующим образом:

request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')

3. Устанавливаем обработчик на смену состояния запроса. В большинстве случаев, нам надо просто дождаться, пока запрос не завершится и затем выполнить обработку результата либо ошибок. При завершении запроса параметр readyState будет равен XMLHttpRequest.DONE (подробнее про значения см. [2]).

request.onreadystatechange = function () {
    if (request.readyState === XMLHttpRequest.DONE) {
        if (request.status === 200) {
            console.log(request.responseText)
        } else {
            console.log("HTTP request failed", request.status)
        }
    }
}

Наша анонимная функция будет вызывать при каждом изменении свойства readyState. Нас интересует завершение запроса, после которого мы проверяем, успешно ли он выполнился. Для этого мы сверяем код его статус с кодом успешного завершения (200). HTTP является текстовым протоколом и помимо числовых значений кодов, передается еще и текстовое описание, так что можно сравнивать свойство statusText со строкой, соответствующей этому статусу, в данном случае, это строка «OK»:

if (request.statusText === 'OK')

В случае ошибки, status и statusText будут содержать код и текстовое описание кодов состояния HTTP (например, 404 и «Not Found» соответственно).

4. Отправляем запрос.

request.send()

В случае POST, здесь же нужно передать параметры запроса:

request.send('param1=value1&param2=value2')

В параметрах запроса можно передавать далеко не все символы. Поэтому и параметр и значение стоит кодировать и, если надо, соответственно декодировать специальными функциями — encodeURIComponent() и decodeURIComponent(). Пример использования:

request.send('%1=%2'.arg(encodeURIComponent(param)).arg(encodeURIComponent(value)))

Рекомендуется закодированную строку еще дополнительно обработать и заменить последовательность "%20" (т.е. закодированный пробел) на символ '+'. Перед декодированием, соответственно, сделать наоборот.

Обычно параметрами запроса передаются значения простых типов. Можно передать и массив, но синтаксис несколько мутный. Например, отправка массива params из двух значений будет выглядеть так:

request.send('params[]=value1&params[]=value2')

Если изловчиться, то можно в качестве значений передавать даже объекты (!), но это может быть не совсем надежно, в том плане, что на принимающей стороне он может превратится в массив :)

Используя POST-запросы мы можем передавать данные не только параметрами запроса но и в самом теле запроса. Например, можно отправить данные в формате JSON. Для этого нужно установить правильный Content-Type и размер содержимого (Content-Length). Пример отправки такого запроса:

request.setRequestHeader('Content-Type', 'application/json')
var params = {
    param1: value1,
    param2: value2
}
var data = JSON.stringify(params)
request.setRequestHeader('Content-Length', data.length)
request.send(data)

Здесь JSON — это глобальный объект доступный в QML, предоставляющий средства по работе с данным форматом [3].

Фактически, формат, в котором мы можем передавать данные, определяется сервером. Если он принимает JSON — отлично, шлем JSON. Ожидает, что данные придут параметрами запроса — значит так и надо отправлять.

Теперь, когда мы изучили необходимые теоретические сведения, приступим к практике и поработаем с ВКонтакте.

Получение и отображение списка друзей

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

Большую часть кода составляет интерфейс отображения и его особо описывать нет смысла. Отмечу только, что если в качестве модели используется JavaScript объект или массив, то для получения к данным модели используется modelData вместо model.

Наиболее интересная часть здесь — это работа с сервером. Для доступа к API ВКонтакте есть специальный адрес: api.vk.com/method/. К полученному адресу мы добавляем название метода (список методов можно посмотреть в [4]), в нашем случае это метод friends.get. На этот адрес нужно отправить POST или GET-запрос с необходимыми параметрами. Ответ придет в формате JSON. Нам нужно в параметре uid передать ID пользователя. Также в параметре fields передадим еще photo_medium, чтобы получить фото и чтобы оно было не самого маленького размера.

Ниже собственно исходный текст программы. В качестве userId в main задается ID пользователя.

import QtQuick 2.0

Rectangle {
    id: main

    property int userId: XXX
    property var friends

    width: 320
    height: 640
    color: 'skyblue'

    function getFriends() {
        var request = new XMLHttpRequest()
        request.open('POST', 'https://api.vk.com/method/friends.get')
        request.onreadystatechange = function() {
            if (request.readyState === XMLHttpRequest.DONE) {
                if (request.status && request.status === 200) {
                    console.log("response", request.responseText)
                    var result = JSON.parse(request.responseText)
                    main.friends = result.response
                } else {
                    console.log("HTTP:", request.status, request.statusText)
                }
            }
        }
        request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
        request.send('fields=photo_medium&uid=%1'.arg(main.userId))
    }

    ListView {
        id: view

        anchors.margins: 10
        anchors.fill: parent
        model: friends
        spacing: 10

        delegate: Rectangle {
            width: view.width
            height: 100
            anchors.horizontalCenter: parent.horizontalCenter
            color: 'white'
            border {
                color: 'lightgray'
                width: 2
            }
            radius: 10

            Row {
                anchors.margins: 10
                anchors.fill: parent
                spacing: 10

                Image {
                    id: image

                    height: parent.height
                    fillMode: Image.PreserveAspectFit
                    source: modelData['photo_medium']
                }

                Text {
                    width: parent.width - image.width - parent.spacing
                    anchors.verticalCenter: parent.verticalCenter
                    elide: Text.ElideRight
                    renderType: Text.NativeRendering
                    text: "%1 %2".arg(modelData['first_name']).arg(modelData['last_name'])
                }
            }
        }
    }

    Component.onCompleted: {
        getFriends()
    }
}

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

Запустив программу, если был указан действительный ID, мы получим примерно такую картину:

Интеграция приложения на QML с веб ресурсами

Самая большая сложность здесь именно в работе с XHR. Попробуем разобраться с этим и немного упростить код.

Упрощение работы с XMLHttpRequest

В работе с XHR есть две сложности.

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

Я написал небольшую библиотеку на JavaScript, которая преобразует объект в параметры запроса, все кодирует, в общем, выдает готовую строку, которую можно сразу отправлять. Также есть функция, которая декодирует параметры запроса и создает из них объект (но она поддерживает только простые типы, массив или объект в параметрах не распарсит, впрочем, вряд ли это понадобится). Взять можно здесь: github.com/krnekit/qml-utils/blob/master/qml/URLQuery.js.

2. В зависимости от типа запроса, данные отправлять нужно по-разному, да еще и может понадобиться устанавливать дополнительно заголовки. Я написал библиотеку, которая упрощает отправку XHR, предоставляя единый интерфейс. Она может отправлять данные в любом формате, для этого можно передать параметром тип содержимого, по умолчанию считается все тот же «application/x-www-form-urlencoded», при этом стоит помнить, что данные другого типа при помощи GET-запроса передавать нельзя, в таком случае нужно будет использовать POST. Content-Length так же автоматически посчитается и установится. Принимает типа запроса, URL, функцию-callback (опционально), которая будет вызвана при завершении запроса и тип данных (опционально). Функция возвращает сам объект запроса или null в случае ошибки. Взять можно здесь: github.com/krnekit/qml-utils/blob/master/qml/XHR.js

Используя две данные библиотеки я упростил предыдущий пример. Весь код здесь приводить не буду, рассмотрим только то, что изменилось.
В начале файла мы подключаем библиотеки (в данном примере, файлы библиотек лежат в том же каталоге, что и qml-файл):

import 'URLQuery.js' as URLQuery
import 'XHR.js' as XHR

Мы импортируем библиотеки и задаем для них пространства имен (namespace), через которые мы будем обращаться к функциям из библиотек.

Функция, отправляющая XHR выглядит теперь так:

function getFriends() {
    var params = {
        fields: 'photo_medium',
        uid: main.userId
    }

    function callback(request) {
        if (request.status && request.status === 200) {
            console.log("response", request.responseText)
            var result = JSON.parse(request.responseText)
            main.friends = result.response
        } else {
            console.log("HTTP:", request.status, request.statusText)
        }
    }

    XHR.sendXHR('POST', 'https://api.vk.com/method/friends.get', callback, URLQuery.serializeParams(params))
}

Для начала мы определяем объект с параметрами запроса. Затем функцию-callback, которая вызовется при завершении запроса. Функция получает параметром сам запрос. И затем отправляем сам запрос, преобразовав объект с параметрами при помощи функции serializeParams.
В итоге, размер кода, можно сказать, не изменился, но зато он стал гораздо более структурированным и понятным.

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

Авторизация ВКонтакте из QML

Не все методы работают без авторизации, так что скорее всего, нам нужно будет авторизоваться. В результате мы должны получить т.н. Authorization Token, который затем будем передавать в запросах к ВКонтакте. Для того, чтобы мы могли авторизоваться, нужно создать в ВКонтакте приложение. Сделать это можно здесь: vk.com/editapp?act=create. Выбираем тип Standalone-приложение. Затем его ID мы будем передавать одним из параметров запроса.

1. Способы авторизации

Поскольку мы делаем standalone-приложение, то есть два способа авторизации, у обоих есть свои проблемы, так что нужно выбрать наименьшее зло :)

1. Прямая авторизация. Посылается HTTP-запрос с данными для логина на определенный адрес. В ответ придут данные в формате JSON, содержащие токен или описание ошибки.

Преимущества:

  • Простота.

Недостатки:

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

2. Авторизация OAuth. Реализуется следующим образом. В программу нужно встроить браузер, в котором пользователю показать специальную страницу логина. После авторизации произойдет перенаправление на другую страницу и в текущем URL будет находиться токен либо описание ошибки. ВКонтакте этот способ позиционирует как основной.

Преимущества:

  • Главное и очень существенное преимущество в том, что он работает для всех приложений и для приложений, которым не разрешили прямую авторизацию, это вообще единственный способ.
  • Не надо передавать секретный ключ.
  • OAuth является стандартом и точно также можно авторизоваться в Facebook, например.

Недостатки, впрочем, тоже существенные.

  • Нужно открывать страницу ВКонтакте, а значит либо пытаться встроить ее в окно программы либо открывать в отдельном окне.
  • Поскольку мы открываем страницу, то нам нужен и браузер. Соответственно, придется тащить QtWebkit и все, что он за собой потянет, отчего программа прибавит в весе.
  • Нужно будет перехватывать события смены URL встроенного браузера, парсить этот URL и выбирать из него параметры, что несколько сложнее, чем XHR.
2. Прямая авторизация

Я, конечно, запросил чтобы мне включили возможность прямой авторизации, но поддержка ВКонтакте сначала неторопливо расспрашивала меня, что да зачем мне надо, а потом полный доступ все-таки зажала :( Так что рассмотрим чисто теоретически. Выглядеть это будет примерно так:

function login() {
    var params = {
        grant_type: 'password',
        client_id: 123456,
        client_secret: 'XXX',
        username: 'XXX',
        password: 'XXX',
        scope: 'audio'
    }

    function callback(request) {
        if (request.status && request.status === 200) {
            console.log("response", request.responseText)
            var result = JSON.parse(request.responseText)
            if (result.error) {
                console.log("Error:", result.error, result.error_description)
            } else {
                main.authToken = result.auth_token
                // Now do requests with this token
            }
        } else {
            console.log("HTTP:", request.status, request.statusText)
        }
    }

    XHR.sendXHR('POST', 'https://oauth.vk.com/token', callback, URLQuery.serializeParams(params))
}

В начале формируем параметры, в них я для примера указал, что требуется доступ к аудио записям пользователя (параметр scope). Затем функция-callback, которая в случае ошибки пишет в консоль, а в случае успеха сохраняет токен и дальше уже могут идти запросы к API.

На всякий случай, оставлю ссылку на документацию: vk.com/dev/auth_direct.

3. Авторизация через OAuth.

Для этого типа авторизации нам нужно показать пользователю веб-страницу логина. В QtQuick есть компонент WebView, позволяющий встроить в приложение на QML браузер на движке WebKit. После того, как пользователь авторизуется, URL в браузере сменится и, в случае удачной авторизации будет содержать токен в параметрах запроса или описание ошибки в якоре [5].

Чтобы не морочиться с разбиранием этого URL, используем функцию parseParams из URLQuery. Ей можно передать сразу весь URL, на выходе мы получим объект с параметрами.

Ниже описан компонент, реализующий этот функционал.

LoginWindow.qml:

import QtQuick 2.0
import QtQuick.Window 2.0
import QtWebKit 3.0
import "URLQuery.js" as URLQuery

Window {
    id: loginWindow

    property string applicationId
    property string permissions
    property var finishRegExp: /^https://oauth.vk.com/blank.html/

    signal succeeded(string token)
    signal failed(string error)

    function login() {
        var params = {
            client_id: applicationId,
            display: 'popup',
            response_type: 'token',
            redirect_uri: 'http://oauth.vk.com/blank.html'
        }
        if (permissions) {
            params['scope'] = permissions
        }

        webView.url = "https://oauth.vk.com/authorize?%1".arg(URLQuery.serializeParams(params))
    }

    width: 1024
    height: 768

    WebView {
        id: webView

        anchors.fill: parent

        onLoadingChanged: {
            console.log(loadRequest.url.toString())

            if (loadRequest.status === WebView.LoadFailedStatus) {
                loginWindow.failed("Loading error:", loadRequest.errorDomain, loadRequest.errorCode, loadRequest.errorString)
                return
            } else if (loadRequest.status === WebView.LoadStartedStatus) {
                return
            }

            if (!finishRegExp.test(loadRequest.url.toString())) {
                return
            }

            var result = URLQuery.parseParams(loadRequest.url.toString())
            if (!result) {
                loginWindow.failed("Wrong responce from server", loadRequest.url.toString())
                return
            }
            if (result.error) {
                loginWindow.failed("Error", result.error, result.error_description)
                return
            }
            if (!result.access_token) {
                loginWindow.failed("Access token absent", loadRequest.url.toString())
                return
            }

            succeeded(result.access_token)
            return
        }
    }
}

Мы отображаем этот компонент в отдельном окне. После вызова метода login(), будет загружена страница логина.

Интеграция приложения на QML с веб ресурсами

После авторизации будет совершен переход на URL в котором в качестве адреса будет oauth.vk.com/blank.html, а затем через '?' или '#' будет идти результат. Параметром permissions мы задаем необходимые нам права доступа. Если мы там что-то указываем, то при логине через наш виджет пользователь увидит диалог предоставления прав доступа приложению.

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

Ну а теперь рассмотрим саму программу, которая этот виджет использует. Программа в случае успешной авторизации устанавливает статус пользователя в «test». ID пользователя задается свойством userId в main.

import QtQuick 2.0
import 'URLQuery.js' as URLQuery
import 'XHR.js' as XHR

Rectangle {
    id: main

    property int userId: XXX
    property var authToken

    width: 640
    height: 320

    function processLoginSuccess(token) {
        loginWindow.visible = false
        authToken = token

        setStatus()
    }

    function setStatus() {
        var params = {
            access_token: main.authToken,
            text: 'test'
        }

        function callback(request) {
            if (request.status == 200) {
                console.log('result', request.responseText)
                var result = JSON.parse(request.responseText)
                if (result.error) {
                    console.log('Error:', result.error.error_code,result.error.error_msg)
                } else {
                    console.log('Success')
                }
            } else {
                console.log('HTTP:', request.status, request.statusText)
            }

            Qt.quit()
        }

        XHR.sendXHR('POST', 'https://api.vk.com/method/status.set', callback, URLQuery.serializeParams(params))
    }

    LoginWindow {
        id: loginWindow

        applicationId: XXX
        permissions: 'status'
        visible: false
        onSucceeded: processLoginSuccess(token)
        onFailed: {
            console.log('Login failed', error)
            Qt.quit()
        }
    }

    Component.onCompleted: {
        loginWindow.visible = true
        loginWindow.login()
    }
}

После загрузки нам покажется окно логина. После логина оно скрывается и отправляется запрос на сервер для смены статуса пользователя. После этого, программа пишет в консоль результат и завершается.

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

Для чего еще можно применять XMLHttpRequest

Расскажу небольшую историю из своего опыта, не связанную с ВКонтакте, но зато связанную с XHR.

Как-то у моего коллеги возникла задача получать и обрабатывать в QML данные в формате XML.

В QtQuick есть специальный тип XmlListModel, способный вытащить из сети, распарсить и представить в виде модели XML-файл. Ему нужно задать запрос типа XPath, в соответствии с которым будет наполняться модель. Проблема была в том, что XML-файл содержал не только элементы, которые нужно было выбрать и поместить в модель но и некоторую дополнительную информацию, которую тоже нужно было получить.

Методов решения несколько. Можно использовать два объекта XmlListModel, но это однозначный костыль, к тому же не хотелось, чтобы XML-файл перекачивался два раза (а он будет, проверено). Можно реализовать этот функционал используя Qt, который содержит аж целых несколько вариантов парсеров, но было желание решить задачу на чистом QML.

Поскольку XMLHttpRequest изначально задумывался для работы именно с XML, в нем есть средства для работы с XML. Соответственно, можно получить и распарсить XML его средствами и выбрать нужную информацию. Затем можно этот же XML передать в XmlListModel (туда можно передавать не только URI, но и содержимое XML-файла).

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

Небольшое резюме

QML содержит много инструментов, доступных для JavaScript в браузере. XMLHttpRequest позволяет отправлять HTTP-запросы и тем самым обеспечивать интеграцию приложения на QML с веб-ресурсами. Использование XHR позволяет во многих случаях обойтись без использования C++ для обмена данными с сервером и тем самым упростить разработку.

Автор: BlackRaven86

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js