Сценарии использования ServerSocket

от автора

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

Если бы оба участника обмена данными были Android‑приложениями, мы могли бы использовать стандартные механизмы IPC, такие как Intent для отправки сообщений, ContentProvider для доступа к данным или Binder для более сложных взаимодействий. Но в нашем случае одна из сторон — браузер, в котором работает веб‑приложение. Браузер не имеет доступа к этим механизмам, так как работает в своем песочном окружении и не может напрямую взаимодействовать с компонентами Android.

Именно поэтому ServerSocket становится удобным решением, позволяя Android‑приложению создать локальный сервер, к которому браузер может подключаться как к обычному веб‑серверу. Это дает возможность гибко передавать данные между приложением и сайтом, обходя ограничения стандартных средств IPC.

Сценарии использования

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

В прошлый раз мы обменивались данными между процессами напрямую, без использования протоколов. Но в случае с браузером такой подход невозможен из‑за ограничений на низкоуровневые сетевые соединения. Браузер не позволяет устанавливать прямое TCP‑соединение с ServerSocket, поэтому нам необходимо использовать один из поддерживаемых протоколов — HTTP или WebSocket. В зависимости от выбора технологии серверная имплементация будет различаться.

Протокол HTTP

Для соединения по HTTP на клиентской стороне можно использовать такой скрипт на HTML:

<!DOCTYPE html> <html> <body> <p id="message"></p>  <script> function read() {     const message = document.getElementById("message");      fetch("http://localhost:65111/")      .then(response => response.text())         .then(responseData => {             message.innerHTML = `Server Message: ${responseData}`;         }) } read() </script> </body> </html>

Этот скрипт выполняет HTTP‑запрос с браузера к локальному серверу, который работает на localhost:65111.

Создание серверной части

Серверный код будет такой:

fun run() { // Создаем экземпляр класса ServerSocket val server: ServerSocket = createServer() // Дожидаемся подключение клиента val clientSocket: Socket = server.accept() // Обмениваемся сообщениями handleClient(clientSocket) }

Создание сервера:

val port = 65111 fun createServer(): ServerSocket {        // Задаем нужные параметры val server = ServerSocket(     port = port,     backlog = 100,     bindAddr = InetAddress.getLoopbackAddress(), ) // Устанавливаем таймаут server.soTimeout = 60.seconds.inWholeMilliseconds.toInt() return server }

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

val ports = listOf(65111, 65112, 65113, 65114, ...) fun createServer(): ServerSocket { for (port in ports) {     try {         return ServerSocket(port, …)     } catch (e: BindException) {         // log or do something     } } error("Could not create ServerSocket") }

Для экономии ресурсов устройства устанавливаем таймаут на установку соединения. Если клиент в течение 60 секунд не подключится, сервер будет уничтожен, а точнее выбросится исключение SocketTimeoutException.

Обмениваемся сообщениями. Вспоминаем, что HTTP‑запрос — это текст, состоящий из стартовой строки и заголовков:

fun handleClient(clientSocket: Socket) { val reader = BufferedReader(InputStreamReader(clientSocket.getInputStream()))     // Читаем начальную строку запроса val request = requestParts(reader)     // Читаем заголовки val headers = readHeaders(reader)     // Отправляем сообщение     sendMessage(clientSocket) writer.close() clientSocket.close() }

Перед HTTP‑заголовками клиентский запрос начинается со стартовой строки запроса, которая содержит следующие компоненты:

  • метод запроса — определяет тип операции, например GET, POST, PUT, DELETE;

  • запрашиваемый ресурс — указывает на целевой ресурс, например URL на сервере;

  • версию HTTP — версию протокола, используемую для запроса.

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

fun requestParts(reader: BufferedReader): Map<String, String> = buildMap {     val request = reader.readLine() val requestParts = request.split(" ") put("method", requestParts[0]) // Метод запроса (GET, POST и т. д.) put("resource", requestParts[1]) // Запрашиваемый ресурс (URL) put("httpVersion", requestParts[2]) // Версия протокола }

После начальной строки запроса следуют HTTP‑заголовки. Они предоставляют дополнительную информацию о запросе или клиенте. Например, информацию о браузере (User‑Agent), предпочтения по содержимому (Accept) и другое, что позволит более тонко настроить ответ.

Каждый заголовок состоит из имени заголовка, последующего двоеточия и значения и разделяется переводом строки:

fun readHeaders(reader: BufferedReader): MutableMap<String, String> {        val headers = mutableMapOf<String, String>() var line = reader.readLine() while (line.isNotEmpty()) {     val headerParts = line.split(":")     if (headerParts.size >= 2) {         val headerName = headerParts[0].trim()         val headerValue = headerParts[1].trim()         headers[headerName] = headerValue     }     line = reader.readLine() } return headers }

Для отправки HTTP‑ответа клиенту нужно отправить необходимые HTTP‑заголовки.

HTTP/1.1 200 OK — строка ответа, указывающая на версию HTTP‑протокола и статус ответа (200), который сообщает о том, что запрос был успешно обработан.

Content‑Type: text/plain — заголовок, определяющий MIME‑тип содержимого ответа. В данном случае сообщается, что ответ представлен в формате обычного текста.

Access‑Control‑Allow‑Origin:* — заголовок используется для указания, что ресурс может быть доступен с любого источника.

Content‑Length: ${response.length} — заголовок, указывающий на длину тела ответа в байтах.

fun sendMessage(clientSocket: Socket) { val writer = PrintWriter(clientSocket.getOutputStream(), true) val response = "Hello Client!" writer.println("HTTP/1.1 200 OK") writer.println("Content-Type: text/plain") writer.println("Access-Control-Allow-Origin: *") writer.println("Content-Length: ${response.length}") writer.println() writer.println(response) } 

Завершается процедура закрытием потока вывода и самого сокета. На этом сервер готов отправить сообщение Hello World! каждому клиенту, который подключится.

Запустив сервер и открыв HTML‑страницу в браузере мобильного устройства, мы можем увидеть результат.

Протокол WebSocket

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

<!DOCTYPE html> <html> <body> <p></p> <font size="4"> <div id="messages"></div> </font> <input type="text" id="inputMessage" placeholder="Enter your message"> <button onclick="sendMessage()">Send</button>  <script> function addMessage(message) {   const logDiv = document.getElementById('messages');   const p = document.createElement('p');   p.textContent = message;   logDiv.appendChild(p);   }    const socket = new WebSocket('ws://localhost: 65111');    socket.addEventListener('message', function (event) {    addMessage('Server: ' + event.data);    });    function sendMessage() {    const inputMessage = document.getElementById("inputMessage").value;    if (socket.readyState === WebSocket.OPEN) {        socket.send(inputMessage);        addMessage('You: ' + inputMessage);        document.getElementById("inputMessage").value = "";    } else {        addMessage('WebSocket is not open. Cannot send message.');    }    } </script> </body> </html>

Изменится серверный код:

fun run() { // Создание сервера не изменится val server: ServerSocket = createServer() val clientSocket: Socket = server.accept() // Изменится установка соединения с клиентом handleClient(clientSocket) }  fun handleClient(clientSocket: Socket) { val inputStream = clientSocket.getInputStream() val outputStream = clientSocket.getOutputStream() val reader = BufferedReader(InputStreamReader(inputStream)) val writer = PrintWriter(outputStream) // Считываем заголовки val headers = readHeaders(reader) // Рукопожатие handshake(writer, headers) // Обмен сообщениями runCommunication(inputStream, outputStream) }

Считывание заголовков остается неизменным. На этот раз они понадобятся нам для этапа Handshake.

Установление WebSocket‑соединения начинается с критического этапа — рукопожатия (handshake), которое обеспечивает переключение с HTTP‑протокола на WebSocket. Рассмотрим, как это реализуется в коде:

fun handshake(writer: PrintWriter, headers: Map<String, String>) {        // Вычисляем ключ для подтверждения установки соединения val encodedKey = calculateWebSocketAccept(headers["Sec-WebSocket-Key"] // Отправляем заголовки на успешное рукопожатие, это константы, закрепленные в RFC для установки соединения по websocket writer.println("HTTP/1.1 101 Switching Protocols") writer.println("Upgrade: websocket") writer.println("Connection: Upgrade")        // Ключ, подтверждающий успешное установление соединения writer.println("Sec-WebSocket-Accept: $encodedKey") writer.println() writer.flush() }

Метод calculateWebSocketAccept вычисляет ключ подтверждения для обеспечения безопасности соединения, принимает строку Sec-WebSocket-Key, которую клиент отправляет в запросе. Это значение преобразуется и отправляется обратно клиенту для завершения рукопожатия.

Процесс вычисления ключа описан в RFC 6455. А вот так выглядит реализация:

fun calculateWebSocketAccept(clientWebSocketKey: String): String {        // Константа из RFС для вычисления ключа val WEB_SOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" val concatenated = clientWebSocketKey + WEB_SOCKET_GUID val sha1 = MessageDigest.getInstance("SHA-1") val hashBytes = sha1.digest(concatenated.toByteArray()) val encodedBytes = Base64.getEncoder().encode(hashBytes) return String(encodedBytes) }

После успешного соединения с клиентом можем начать общение. Дожидаемся сообщения от клиента и отправляем ему ответ:

fun runCommunication(input: InputStream, output: OutputStream) {        while (true) {                 // Сообщения, получаемые из сокета, присылаются в виде фрейма     val receivedFrame = readSocketFrame(input)            // Отправляем ответ на сообщение клиента         sendText(output, "Hello Client!")     } }

Функция readSocketFrame отвечает за получение фрейма сообщения от клиента. В WebSocket сообщения разбиваются на фреймы.

Фрейм — это последовательность байтов, где первые шесть байтов содержат служебную информацию, а оставшиеся байты — данные.

Визуальное представление фрейма в виде битов

Визуальное представление фрейма в виде битов

Как происходит чтение фрейма:

private fun readSocketFrame(input: InputStream): String {        // Первый байт, биты в нем рассказывают о типе сообщения, для примера не важно, пропускаем описание каждого бита в этом байте val b1 = input.read() // Второй байт val b2 = input.read() // Последние 7 битов обозначают длину сообщения (максимальная длина сообщения — 125 байт, если нужно больше, придется разбираться, как это устроено) val messageLength = (b2 and 0b01111111).toLong() // Следующие 4 байта — это маска, которой зашифровано сообщение val maskKey = input.getBytes(4) // Все последующие байты — это наше сообщение val payload = input.getBytes(messageLength) // Расшифровываем сообщение unmaskedPayload(payload, maskKey) // Возвращаем результат return String(payload) }  // Алгоритм из RFC для расшифровывания сообщения private fun unmaskedPayload(payload: ByteArray, maskKey: ByteArray) { for (index in payload.indices) {     payload[index] = payload[index] xor maskKey[index % 4] } } 

Отправляем ответ, где первые два байта — это заголовки и остальное тело сообщения:

private fun sendText(output: OutputStream, text: String) {        val utf8Bytes = text.toByteArray()        // Первый байт, говорящий, что мы отправляет текст output.write(0b10000001)        // Второй байт — длина сообщения output.write(utf8Bytes.size)        // И само сообщение output.write(utf8Bytes) output.flush() }

В примере я минимально рассмотрел протокол Websocket. Описанной функциональности хватит для банального общения текстом, когда не нужно дробить большие сообщения на маленькие кусочки. Вот что получилось:

Для демонстрации использовал Split Screen mode

Для демонстрации использовал Split Screen mode

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

График показывает, что время работы ServerSocket на обычных вендорских устройствах составляет до 8 минут, в то время как на Samsung сервер убивается системой уже через 2 минуты

График показывает, что время работы ServerSocket на обычных вендорских устройствах составляет до 8 минут, в то время как на Samsung сервер убивается системой уже через 2 минуты

Выводы

Мы рассмотрели, как организовать общение между двумя приложениями на одном устройстве, где клиентом выступает веб‑страница в браузере. Разобрали, какие существуют ограничения на формат общения, а также ограничения по времени работы из‑за политики OC управления фоновыми процессами.

В следующей статье расскажу, как организовать общение приложений в одной Wi-Fi‑сети.


ссылка на оригинал статьи https://habr.com/ru/articles/893890/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *