システム開発部のTです。
普段はAndroid、iOSなどのネイティブアプリ開発やフロントエンドアプリ開発に携わっています。

今回、WebSocket通信を使ったクライアントアプリを開発したのですが、結合テスト時にサーバーとのデータ連携を確認するため、都合のいいサーバーがほしいが思ったものが見当たらず、では自作しよう!ってことで、Android端末上で動作するWebSocketサーバーを構築することとなりました。

今回、そのサーバーアプリの開発手段を紹介します。

開発環境

本件では、以下の開発環境を利用しました。

統合環境Android StudioElectric Eel 2022.1.1 Patch 2
開発言語Kotlin1.8.0
動作環境Android端末対象SDK:33(最小SDK:24)
利用ライブラリKtor2.2.4

ライブラリ

本件では、Kotlin製WebアプリケーションフレームワークのKtorを使います。
build.gradle(app)に以下を反映してください。

android {
              ・ 
              ・ 
              ・ 
    viewBinding {  // 追加は任意ですが、本件では利用前提とします
        enabled = true
    }
    packagingOptions {  // ktor利用時は追加
        exclude 'META-INF/INDEX.LIST'
        exclude 'META-INF/io.netty.versions.properties'
    }
}

dependencies {
              ・ 
              ・ 
              ・ 
    // 以下を反映してください
    implementation "io.ktor:ktor-server-core:2.2.4"
    implementation "io.ktor:ktor-server-netty:2.2.4"
    implementation "io.ktor:ktor-websockets:2.2.4"
    implementation "io.ktor:ktor-server-status-pages:2.2.4"
    implementation "io.ktor:ktor-server-default-headers:2.2.4"
    implementation "io.ktor:ktor-server-websockets:2.2.4"
}

WebSocketサーバーの実装

まずは、MyWebsocketServerというクラスを作成し、ローカル環境下で実行してみます。
以下のように実装します。

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.util.*
import io.ktor.websocket.*
import kotlinx.coroutines.channels.consumeEach
import java.time.Duration

class MyWebsocketServer {
    private val netty = embeddedServer(Netty, port = 8000) {
        install(WebSockets) {
            timeout = Duration.ofSeconds(5)
            pingPeriod = Duration.ofMinutes(1)
        }
        routing {
            webSocket("/") {
                val uniqueId = generateNonce()
                println("Connection to $uniqueId established")
                incoming.consumeEach { frame ->
                    if (frame is Frame.Text) {
                        println("Receive Message: ${frame.readText()}")
                        outgoing.send(Frame.Text("Receive Message: ${frame.readText()}"))
                    }
                }
                println("Connection to $uniqueId closed")
            }
        }
    }

    fun start() {
        netty.start(wait = true)
    }

    fun stop() {
        netty.stop()
    }
}

fun main() {
    MyWebsocketServer().start()
}

その後、MyWebsocketServerを実行してみてください。
main()関数を実装していると、メニューに「Run ‘MyWebsocketServerKt’」が表示されるため、直接クラスを実行可能になります。

実行すると、以下のエラーログが表示されるかもしれませんが、本件では無視してください。
これでサーバーが起動しました。

クライアントからアクセスする

サーバーが起動したので、今度はクライアントからアクセスしてみます。
クライアントはなんでもいいのですが、私自身はChromeブラウザの拡張アプリとして「websocket test client」というのを利用しました。

URLに「ws://localhost:8000/」と入力し、「Open」ボタン押下で接続します。
Statusが「OPENED」になることを確認してください。

今度はRequestに「こんにちは」と入力し、「Send」ボタンを押下してください。
すると、赤文字で「こんにちは」の後に、「Receive Message:こんにちは」と返ってくるかと思います。

とりあえず、上記をもってWebsocketServerが動作していることは確認できたかと思います。
では、コードに戻ってみましょう。

コードリーディング

実装内容を見ていきましょう。

    private val netty = embeddedServer(Netty, port = 8000) {
             ・
             ・
             ・
    }

サーバーの定義になります。
本件では、Nettyを利用し、port8000としています。
NettyはWebアプリケーションフレームワークの一種ですが、本件では説明は割愛します。

    private val netty = embeddedServer(Netty, port = 8000) {
        install(WebSockets) {
            timeout = Duration.ofSeconds(5)
            pingPeriod = Duration.ofMinutes(1)
        }

install(WebSockets)でWebSocketモジュールを読み込みます。
クロージャー内で、timeout等の定義をします。
続けてroutingの定義です。

    private val netty = embeddedServer(Netty, port = 8000) {
        install(WebSockets) {
            timeout = Duration.ofSeconds(5)
            pingPeriod = Duration.ofMinutes(1)
        }
        routing {
            webSocket("/") {
                // クライアント接続時の処理を実装
            }
        }
    }

routing内でwebSocket("{エンドポイント}")と定義し、クロージャー内でクライアント接続時の処理を実装します。
本件では、エンドポイントを「/」としているので、webSocket("/")としています。

webSocketクロージャー内の説明です。
クライアントからの接続時に処理が実行されます。

            webSocket("/") {
                val uniqueId = generateNonce()
                println("Connection to $uniqueId established")
                incoming.consumeEach { frame ->
                    if (frame is Frame.Text) {
                        println("Receive Message: ${frame.readText()}")
                        outgoing.send(Frame.Text("Receive Message: ${frame.readText()}"))
                    }
                }
                println("Connection to $uniqueId closed")
            }
  • generateNonce
    クライアント接続時、固有のIDを得ることができます。
    IDに紐付かせてデータの保持などに利用できると思いますが、本件ではクライアント接続時、切断時のログ出力時に利用するに留めております。
  • incoming.consumeEach
    incomingReceiveChannelで、クライアントからのデータ受信に利用します。consumeEachメソッドで非同期にデータの受信が可能で、consumeEachのクロージャーで受信データを取得できます。
    本件では、受信データがFrame.Textの場合、ログに"Receive Message: ${frame.readText()}"を出力し、次に説明するoutgoing.sendでクライアントにデータを送信しています。
  • outgoing.send
    outgoingはSendChannelで、クライアントへのデータ送信に利用します。sendメソッドで実際にデータを送信します。本件では、Frame.Textにて文字データをFrameに変換し、send()メソッドで送信しています。

以上がサーバーの定義になります。
次にサーバーを起動します。起動はstart()メソッドを実行します。

    fun start() {
        netty.start(wait = true)
    }

サーバーはnetty.start()で起動します。
これでクライアントからの接続を受け付けます。

逆にサーバーを閉じる場合は、

    fun stop() {
        netty.stop()
    }

netty.stop()を実行することで、クライアントからの接続を切断します。

以上でサーバー側の実装の説明となります。
ここまではクラスを直接実行した形でしたが、今度はAndroid端末上でサーバーを起動してみようと思います。

Android端末でサーバーを起動できるようにする

AndroidManifest.xmlに以下のuser-permissionを追加してください。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- 以下の3つのパーミッションを追加 -->
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" android:maxSdkVersion="30"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"

続けて、layout.xmlを以下の内容で定義します。
本件では、layout.xmlをactivity_main.xmlとします。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="16dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:orientation="horizontal">

            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="IPアドレス:"/>

            <TextView
                android:id="@+id/ip_address_text"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                tools:text="192.168.0.1"/>
        </LinearLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:orientation="horizontal">

            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="PORT:"/>

            <TextView
                android:id="@+id/ip_port_text"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="8000"/>
        </LinearLayout>
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

レイアウトの内容としては、サーバー接続時のIPアドレスとポート番号を表示するのみとしています。

最後にActivityクラスの実装です。
本件では、MainActivityとします。

import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import com.websocketserver.databinding.ActivityMainBinding
import java.net.Inet4Address
import kotlin.concurrent.thread

class MainActivity : AppCompatActivity() {
    private var mWebSocketServer: MyWebsocketServer? = null
    private lateinit var binding: ActivityMainBinding

    private var mHandler = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        Handler.createAsync(Looper.getMainLooper())
    } else {
        @Suppress("DEPRECATION")
        (Handler())
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // サーバーを起動
        mWebSocketServer = MyWebsocketServer()
        thread {
            mWebSocketServer?.start()
        }

        // 端末のIPアドレスを取得
        val manager = this.applicationContext.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
        val networkCallback = object: ConnectivityManager.NetworkCallback() {
            override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
                super.onLinkPropertiesChanged(network, linkProperties)
                mHandler.post {
                    binding.ipAddressText.text = linkProperties.linkAddresses.filter{
                        it.address is Inet4Address
                    }[0].toString()
                }
            }
        }
        manager.registerDefaultNetworkCallback(networkCallback)
    }

    override fun onDestroy() {
        // サーバーを終了
        thread {
            mWebSocketServer?.stop()
            mWebSocketServer = null
        }
        super.onDestroy()
    }
}

本件では、onCreate()MyWebSocketServer()のインスタンスを取得し、mWebSocketServer?.start()をコールしてサーバーを起動しています。thread内でやっているのは、UIスレッドだとエラーになるためです。

onDestroy()でサーバーと閉じる処理を実装しています。

クライアントからサーバーであるAndroid端末への接続先を示すため、端末のIPアドレスを取得する処理をonCreate()で実装しています。なお、本件ではWIFI環境でのローカルネットワークを想定しています。

Android端末でサーバーを起動

それでは、アプリをビルドし、Android端末にアプリをインストールしてください。
インストール後、アプリを起動するとそのままサーバーが起動するかと思います。

続けて、クライアントからws://で始まるURLを設定します。
接続先はAndroid端末のサーバーアプリ画面上に表示されたIPアドレスとポート番号を設定してください。
設定後、「Open」ボタンを押下することで接続されると思います。

Requestに「こんにちは」と入力し、「Send」ボタンを押下すると、MessageLogに赤文字で「こんにちは」とともに、サーバーから返却された文字列「Receive Message:こんにちは」と返ってきたかと思います。

処理としては単純なので実感は沸かないとは思いますが、Android端末でもWebSocketサーバーを起動できたかと思います。

さいごに

以上で、Android端末でWebsocketServerを起動するまでの実装の説明でしたが、いかがだったでしょうか。
私自身も、ここまで実装するまでに試行錯誤していましたが、Ktorを利用することで簡単に少ない工数で実装することができました。

正直なところ、Android端末で本格的なサーバーを構築するというのは厳しいとは思いますが、私のようにクライアントアプリのデバッグ目的で利用する場合は手頃なモックサーバーとして利用できると思います。

Ktorは通常のWebServerとしても利用できるので、興味あれば、以下のサイトを見ていただければと思います。

https://ktor.io/

以上です、ありがとうございました。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる