システム開発部のTです。
普段はAndroid、iOSなどのネイティブアプリ開発やフロントエンドアプリ開発に携わっています。
今回、WebSocket通信を使ったクライアントアプリを開発したのですが、結合テスト時にサーバーとのデータ連携を確認するため、都合のいいサーバーがほしいが思ったものが見当たらず、では自作しよう!ってことで、Android端末上で動作するWebSocketサーバーを構築することとなりました。
今回、そのサーバーアプリの開発手段を紹介します。
開発環境
本件では、以下の開発環境を利用しました。
統合環境 | Android Studio | Electric Eel 2022.1.1 Patch 2 |
開発言語 | Kotlin | 1.8.0 |
動作環境 | Android端末 | 対象SDK:33(最小SDK:24) |
利用ライブラリ | Ktor | 2.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
を利用し、port
は8000
としています。
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
incoming
はReceiveChannel
で、クライアントからのデータ受信に利用します。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としても利用できるので、興味あれば、以下のサイトを見ていただければと思います。
以上です、ありがとうございました。