ネイティブアプリでちょっとかわった技術選定した話

こんにちは。tacomsのエンジニアの梶野です。 今回は最近行った少し特殊な開発要件で技術選定を行いました。 前提として、我々はバックエンドGo+フロントReact, React Nativeを利用してきたチームでの技術選定になります。

実装要件

今回実装したかったのは、以下の要件を満たすアプリです。

  • Androidアプリ
  • 複数の端末で利用(同一の場所)
  • ネットワークが不安定な場合でも利用可能

なので、1台のアプリ内でサーバーを動かすことで ネットワークが不安定な場合でも利用できるように考えました。

1台のアプリでは下記の役割担ってもらうことにしました。

  • RESTful API形式での通信に対応
  • 動的URLルーティングが可能(静的ファイル配信だけでなく、パラメータを含むエンドポイントの実装)
  • 同じネットワーク経由でリクエストを処理できる(他のデバイスからアクセス可能)

Androidアプリ内で動作するRESTful APIサーバーを実装する必要に迫られ技術選定を行いました。当初はReact Nativeのみで実装を検討していましたが、最終的にKotlinとNanoHTTPDを採用することにしました。 ただし、チーム全体の開発効率を考慮し、実装を適切に分割する工夫も行いました。本記事では、その選定プロセスと実装方針、そして具体的なコードサンプルについて解説します。

React Nativeでの実装を断念した理由

最初はReact Nativeでの実装を試みてました。React Nativeであれば、JavaScriptベースで実装できるため開発効率が高いためです。

React Native向けのHTTPサーバーライブラリとして、以下の選択肢を検討しました:

しかし、これらのライブラリを調査する中で、いくつかの課題が明らかになりました。

メンテナンス性の問題

これらのライブラリの多くは、メンテナンス状況が芳しくないことが判明しました。最終更新が数年前であったり、issueに対する対応が滞っていたり、現行のReact Nativeバージョンとの互換性に関する報告が不足していたりと、今回採用するにはいろいろな不安がでてきました。

サービスで利用する以上、長期的なメンテナンス性は重要な選定基準です。また、React Nativeでの開発ではバージョンアップによる互換性のない変更が入ることも少なくありません。メンテナンスが行き届いていないライブラリを採用することは、将来的な技術的負債につながるリスクが高いと判断しました。

動的URLルーティングの困難さ

さらに重要な問題として、これらのライブラリの多くは静的ファイルサーバーとしての利用を主目的としていることがわかりました。特にreact-native-static-serverは、その名前が示す通り、静的ファイルの配信に特化しています。

HTMLやJavaScript、画像などの静的ファイルを配信することには適していますが、RESTful APIのような動的なエンドポイントを実装するには不向きでした。例えば、/api/users/:idのようなパスパラメータを含むエンドポイントや、POSTリクエストのボディを解析して処理する柔軟なルーティング機能が、これらのライブラリでは実装が困難、もしくは非常に煩雑なコードが必要になることが明らかになりました。

react-native-http-bridge-refurbishedは動的なエンドポイントをある程度サポートしているものの、メンテナンス頻度やコミュニティの規模を考慮すると、長期的な採用には懸念が残りました。

外部ネットワークアクセスの制限

さらに、react-native-http-bridge-refurbishedを含む多くのライブラリは、主にlocalhostでのアクセスを前提としており、同じネットワーク上の他のデバイスからアクセスできるかどうかがドキュメント上で明確な記載はありませんでした。 なので今回の要件では、同じWi-Fiネットワークに接続された複数のデバイスからアプリ内のサーバーにアクセスする必要があったため、この点も大きな懸念事項となりました。

NanoHTTPDの採用とハイブリッド実装アーキテクチャ

これらの問題を踏まえ、KotlinでNanoHTTPDを利用する方針に転換しました。 ただし、チームの技術スタックはReact Nativeの運用を行ってきたメンバーが多いことを考慮し、実装を役割によって分割することにしました。

NanoHTTPDとは

NanoHTTPDは、Javaで書かれた軽量なHTTPサーバーライブラリです。わずか数百行のコードで構成されており、依存関係も最小限に抑えられているため、Androidアプリへの組み込みに最適です。

NanoHttpd

採用の決め手

NanoHTTPDを選択した理由は以下の通りです:

1. 動的ルーティングの容易さ
NanoHTTPDでは、リクエストのメソッドやURIを自由に解析できるため、動的なエンドポイントの実装が直感的に行えます。パスパラメータの抽出やクエリパラメータの処理も、標準的なKotlinのコードで柔軟に実装できます。

2. 安定性とメンテナンス性
NanoHTTPDは長年にわたって使用されており、安定性が実証されています。また、シンプルな設計のため、必要に応じてカスタマイズも容易です。

3. Androidネイティブとの親和性
Kotlinで実装することで、Android SDKの機能にも直接アクセスでき、パフォーマンス面でも有利です。React Nativeのブリッジを介さないため、オーバーヘッドも最小限に抑えられます。

4. ネットワークアクセスの柔軟性
NanoHTTPDは0.0.0.0にバインドすることで、同じネットワーク上のどのデバイスからでもアクセス可能です。この点は、複数デバイス間での通信が必要な私たちの要件に完全に合致しました。

チームの技術スタックを考慮した実装分割

ここで重要な課題が浮上しました。チームメンバーの技術スタックが一致していない場合、全員がKotlinのコードをメンテナンスする必要が生じると、開発効率が低下する可能性があります。

責務の明確な分離

この課題に対し、以下のような実装方針で開発するようにしました:

Kotlin(NanoHTTPD)側の責務

React Native側の責務

  • ルーティングロジックの実装
  • エンドポイントごとのビジネスロジック
  • レスポンスの生成

つまり、NanoHTTPD側ではサーバーの起動のみに責務を限定し、リクエストを受け取った後のルーティング処理以降はすべてReact Native側で実装できるようにしました。

実装サンプルコード

動作環境

# React Native
- Current Version: 0.80.2

# Kotlin
- Current Version: 2.2.10

# Android API
- Current compileSdk: 35 (Android 15)
- Current targetSdk: 35 (Android 15)
- Current minSdk: 24 (Android 7.0)

Kotlin側:NanoHTTPDによるサーバー起動

まず、Kotlin側でNanoHTTPDを使用したサーバー起動部分の実装です。この部分は一度実装すれば、ほとんど変更する必要がありません。

package com.example.myapp

import android.util.Log
import fi.iki.elonen.NanoHTTPD
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import org.json.JSONObject

class HttpServerModule(reactContext: ReactApplicationContext) : 
    ReactContextBaseJavaModule(reactContext) {
    
    private var server: EmbeddedHttpServer? = null
    
    override fun getName(): String {
        return "HttpServerModule"
    }
    
    @ReactMethod
    fun startServer(port: Int, promise: Promise) {
        try {
            if (server != null) {
                promise.reject("SERVER_RUNNING", "Server is already running")
                return
            }
            
            server = EmbeddedHttpServer(port, reactApplicationContext)
            server?.start()
            
            Log.d("HttpServer", "Server started on port $port")
            promise.resolve("Server started successfully on port $port")
        } catch (e: Exception) {
            Log.e("HttpServer", "Failed to start server", e)
            promise.reject("START_ERROR", "Failed to start server: ${e.message}")
        }
    }
    
    @ReactMethod
    fun stopServer(promise: Promise) {
        try {
            server?.stop()
            server = null
            Log.d("HttpServer", "Server stopped")
            promise.resolve("Server stopped successfully")
        } catch (e: Exception) {
            Log.e("HttpServer", "Failed to stop server", e)
            promise.reject("STOP_ERROR", "Failed to stop server: ${e.message}")
        }
    }
    
    @ReactMethod
    fun sendResponse(requestId: String, statusCode: Int, body: String, headers: ReadableMap?) {
        server?.sendResponse(requestId, statusCode, body, headers)
    }
}

class EmbeddedHttpServer(
    port: Int, 
    private val reactContext: ReactApplicationContext
) : NanoHTTPD(port) {
    
    private val pendingResponses = mutableMapOf<String, Response>()
    private var requestIdCounter = 0
    
    override fun serve(session: IHTTPSession): Response {
        val requestId = generateRequestId()
        
        // リクエストボディの読み取り
        val bodyMap = mutableMapOf<String, String>()
        try {
            session.parseBody(bodyMap)
        } catch (e: Exception) {
            Log.e("HttpServer", "Failed to parse body", e)
        }
        
        // リクエスト情報をJSONとして構築
        val requestData = JSONObject().apply {
            put("id", requestId)
            put("method", session.method.toString())
            put("uri", session.uri)
            put("headers", JSONObject(session.headers))
            put("params", JSONObject(session.parameters))
            put("body", bodyMap["postData"] ?: "")
        }
        
        // React Native側にイベントを送信
        sendEventToReactNative("onHttpRequest", requestData.toString())
        
        // React Native側からのレスポンスを待機
        return waitForResponse(requestId)
    }
    
    private fun generateRequestId(): String {
        return "req_${System.currentTimeMillis()}_${requestIdCounter++}"
    }
    
    private fun sendEventToReactNative(eventName: String, data: String) {
        reactContext
            .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
            .emit(eventName, data)
    }
    
    private fun waitForResponse(requestId: String): Response {
        // タイムアウト付きでレスポンスを待機
        val startTime = System.currentTimeMillis()
        val timeout = 30000L // 30秒
        
        while (System.currentTimeMillis() - startTime < timeout) {
            synchronized(pendingResponses) {
                pendingResponses[requestId]?.let { response ->
                    pendingResponses.remove(requestId)
                    return response
                }
            }
            Thread.sleep(10)
        }
        
        // タイムアウト時のレスポンス
        return newFixedLengthResponse(
            Response.Status.REQUEST_TIMEOUT,
            "application/json",
            """{"error": "Request timeout"}"""
        )
    }
    
    fun sendResponse(requestId: String, statusCode: Int, body: String, headers: ReadableMap?) {
        val status = when (statusCode) {
            200 -> Response.Status.OK
            201 -> Response.Status.CREATED
            400 -> Response.Status.BAD_REQUEST
            404 -> Response.Status.NOT_FOUND
            500 -> Response.Status.INTERNAL_ERROR
            else -> Response.Status.OK
        }
        
        val response = newFixedLengthResponse(status, "application/json", body)
        
        // カスタムヘッダーの追加
        headers?.toHashMap()?.forEach { (key, value) ->
            response.addHeader(key, value.toString())
        }
        
        synchronized(pendingResponses) {
            pendingResponses[requestId] = response
        }
    }
}

class HttpServerPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
        return listOf(HttpServerModule(reactContext))
    }
    
    override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
        return emptyList()
    }
}

React Native側:ルーティングとビジネスロジック

次に、React Native側でのルーティングとビジネスロジックの実装例です。こちらがチームメンバーが日常的に触る部分になります。

// HttpServer.ts
import { NativeModules, NativeEventEmitter } from 'react-native';

const { HttpServerModule } = NativeModules;
const eventEmitter = new NativeEventEmitter(HttpServerModule);

export interface HttpRequest {
  id: string;
  method: string;
  uri: string;
  headers: Record<string, string>;
  params: Record<string, string[]>;
  body: string;
}

export interface HttpResponse {
  statusCode: number;
  body: string;
  headers?: Record<string, string>;
}

type RouteHandler = (req: HttpRequest) => Promise<HttpResponse> | HttpResponse;

class HttpServer {
  private routes: Map<string, Map<string, RouteHandler>> = new Map();
  private isRunning = false;

  async start(port: number = 8080): Promise<void> {
    if (this.isRunning) {
      throw new Error('Server is already running');
    }

    // リクエストイベントのリスナーを登録
    eventEmitter.addListener('onHttpRequest', this.handleRequest.bind(this));

    // サーバーを起動
    await HttpServerModule.startServer(port);
    this.isRunning = true;
    console.log(`HTTP Server started on port ${port}`);
  }

  async stop(): Promise<void> {
    if (!this.isRunning) {
      return;
    }

    eventEmitter.removeAllListeners('onHttpRequest');
    await HttpServerModule.stopServer();
    this.isRunning = false;
    console.log('HTTP Server stopped');
  }

  // ルーティングの登録
  get(path: string, handler: RouteHandler): void {
    this.addRoute('GET', path, handler);
  }

  post(path: string, handler: RouteHandler): void {
    this.addRoute('POST', path, handler);
  }

  put(path: string, handler: RouteHandler): void {
    this.addRoute('PUT', path, handler);
  }

  delete(path: string, handler: RouteHandler): void {
    this.addRoute('DELETE', path, handler);
  }

  private addRoute(method: string, path: string, handler: RouteHandler): void {
    if (!this.routes.has(method)) {
      this.routes.set(method, new Map());
    }
    this.routes.get(method)!.set(path, handler);
  }

  private async handleRequest(data: string): Promise<void> {
    try {
      const request: HttpRequest = JSON.parse(data);
      console.log(`Received ${request.method} ${request.uri}`);

      // ルーティング処理
      const handler = this.findHandler(request.method, request.uri);
      
      if (!handler) {
        this.sendResponse(request.id, {
          statusCode: 404,
          body: JSON.stringify({ error: 'Not Found' }),
          headers: { 'Content-Type': 'application/json' },
        });
        return;
      }

      // ハンドラーの実行
      const response = await handler(request);
      this.sendResponse(request.id, response);
    } catch (error) {
      console.error('Error handling request:', error);
      const request = JSON.parse(data);
      this.sendResponse(request.id, {
        statusCode: 500,
        body: JSON.stringify({ error: 'Internal Server Error' }),
        headers: { 'Content-Type': 'application/json' },
      });
    }
  }

  private findHandler(method: string, uri: string): RouteHandler | null {
    const methodRoutes = this.routes.get(method);
    if (!methodRoutes) {
      return null;
    }

    // 完全一致を試す
    if (methodRoutes.has(uri)) {
      return methodRoutes.get(uri)!;
    }

    // パスパラメータを含むルートのマッチング
    for (const [pattern, handler] of methodRoutes.entries()) {
      if (this.matchRoute(pattern, uri)) {
        return handler;
      }
    }

    return null;
  }

  private matchRoute(pattern: string, uri: string): boolean {
    const patternParts = pattern.split('/');
    const uriParts = uri.split('?')[0].split('/');

    if (patternParts.length !== uriParts.length) {
      return false;
    }

    return patternParts.every((part, index) => {
      return part.startsWith(':') || part === uriParts[index];
    });
  }

  private sendResponse(requestId: string, response: HttpResponse): void {
    HttpServerModule.sendResponse(
      requestId,
      response.statusCode,
      response.body,
      response.headers || {}
    );
  }
}

export default new HttpServer();

使用例:React Nativeでのルーティング定義

実際にアプリケーションでHTTPサーバーを使用する例です:

// App.tsx
import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import HttpServer from './HttpServer';

const App = () => {
  useEffect(() => {
    // サーバーの起動とルーティングの定義
    const setupServer = async () => {
      try {
        // ルーティングの定義
        HttpServer.get('/api/health', (req) => ({
          statusCode: 200,
          body: JSON.stringify({ status: 'ok', timestamp: Date.now() }),
          headers: { 'Content-Type': 'application/json' },
        }));

        HttpServer.get('/api/users/:id', (req) => {
          const userId = req.uri.split('/')[3];
          return {
            statusCode: 200,
            body: JSON.stringify({
              id: userId,
              name: `User ${userId}`,
              email: `user${userId}@example.com`,
            }),
            headers: { 'Content-Type': 'application/json' },
          };
        });

        HttpServer.post('/api/users', async (req) => {
          try {
            const userData = JSON.parse(req.body);
            // ここでデータベースへの保存などの処理を行う
            return {
              statusCode: 201,
              body: JSON.stringify({
                id: Date.now().toString(),
                ...userData,
              }),
              headers: { 'Content-Type': 'application/json' },
            };
          } catch (error) {
            return {
              statusCode: 400,
              body: JSON.stringify({ error: 'Invalid JSON' }),
              headers: { 'Content-Type': 'application/json' },
            };
          }
        });

        // サーバーの起動
        await HttpServer.start(8080);
        console.log('API Server is ready');
      } catch (error) {
        console.error('Failed to start server:', error);
      }
    };

    setupServer();

    // クリーンアップ
    return () => {
      HttpServer.stop();
    };
  }, []);

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>HTTP Server is running on port 8080</Text>
    </View>
  );
};

export default App;

メリット

1. 学習コストの削減
React Nativeをメインで扱う開発者は、Kotlinのコードに触れる必要がほとんどなくなります。サーバー起動部分は一度実装すれば変更頻度が低いため、日常的な開発ではJavaScript/TypeScriptのみで行うことが可能です。

2. 開発速度の向上
ルーティングやビジネスロジックの実装・修正が、慣れ親しんだReact Nativeの環境で行えるため、開発速度が向上します。上記の例のように、新しいエンドポイントの追加もシンプルなJavaScript/TypeScriptのコードで実装可能です。

3. テストの容易さ
ビジネスロジックがReact Native側に集約されることで、JavaScriptのテストフレームワークを使った単体テストを採用することが可能です。

4. 明確な責務分離
「サーバーの起動」と「アプリケーションロジック」という明確な境界線ができることで、コードの保守性も向上します。

まとめ

Androidアプリ内でRESTful APIサーバーを実装するという要件に対し、当初検討していたReact Nativeライブラリ(react-native-http-server、react-native-http-bridge-refurbished、react-native-static-serverなど)ではなく、KotlinとNanoHTTPDの組み合わせを採用しました。

技術選定においては、実装の容易さだけでなく、メンテナンス性や長期的な運用を見据えた判断が重要です。今回のケースでは、動的URLルーティングという要件、同じネットワーク経由でのアクセス可能性、そしてライブラリのメンテナンス状況を総合的に評価した結果、NanoHTTPDが最適な選択肢となりました。

さらに、チームの技術スタックを考慮し、Kotlinでのサーバー起動部分とReact Nativeでのルーティング・ビジネスロジック部分を明確に分離することで、開発効率を最大化する実装方針を確立しました。この分割により、Kotlinに不慣れなメンバーでも安心して開発に参加できる環境を整えることができました。

同様の課題に直面していることは少ないかも知れませんが、少しでも本記事が参考になれば幸いです。