【Dart】エコーサーバーで学ぶWebSocketの基礎

  • 2024年7月5日
  • 2024年7月5日
  • Dart

対象者

  • Flutterを使用してサーバー通信を学びたい初心者から中級者の開発者
  • 自分のPC上でサーバープログラムを動かし、実機のスマホからアクセスする方法に興味がある方
  • WebSocketの基本的な使い方やサーバーとクライアント間の通信をFlutterで実現する方法を知りたい方

はじめに

今回は、サーバーとの通信を行うFlutterアプリを作成しようと思います。
通常、サーバーはクラウド上に存在しますが、今回は自分のPC上でサーバープログラムを動かし、PCにつないだ実機のスマホからそのサーバーにアクセスする方法を試みました。この方法は初めてでしたが、実際にテストを行い、プログラムが正常に動作することを確認しました。この記事では、そのプログラムを詳しく紹介いたします。
Windows PCにAndroid実機をUSBで接続しているので、ポートの設定が不要でした。実際にするときは、そこの配慮をお願いします。

サーバ側のプログラム

サーバー側のプログラムについて説明します。まず、サーバー側のプログラムを作成しました。このプログラムはWebSocketを使用しており、起動するとWebSocketを開いて外部からのデータを受け取る準備をします。そして、受け取ったデータをそのまま返す、いわゆるエコーサーバーとして機能します。このサーバープログラムをパソコン上で実行することで、外部からの接続が正しく受け付けられるかを確認しようと考えました。

import 'dart:io';

void main() async {
  final server = await HttpServer.bind(InternetAddress.anyIPv4, 5000);
  print('Server running on ${server.address.address}:${server.port}');

  await for (HttpRequest request in server) {
    if (WebSocketTransformer.isUpgradeRequest(request)) {
      WebSocketTransformer.upgrade(request).then(handleWebSocket);
    } else {
      request.response
        ..statusCode = HttpStatus.forbidden
        ..write('WebSocket connections only')
        ..close();
    }
  }
}

void handleWebSocket(WebSocket socket) {
  print('Client connected!');
  socket.listen(
    (data) {
      print('Received: $data');
      socket.add('Echo from Server: $data');
    },
    onDone: () => print('Client disconnected'),
    onError: (error) => print('Error: $error'),
  );
}

詳細解説

  • final server = await HttpServer.bind(InternetAddress.anyIPv4, 5000);
    この行では、IPv4アドレス0.0.0.0(すべてのネットワークインターフェイス)上のポート5000でサーバーを起動します。awaitキーワードにより、サーバーがバインドされるまで処理が待機されます。これにより、指定したポート番号でサーバーがリクエストを受け取る準備が整います。

  • await for (HttpRequest request in server) { ... }
    この行では、サーバーに対するすべてのHTTPリクエストを非同期に待ち受け、HttpRequestオブジェクトとして処理します。await for構文を使用することで、各リクエストを順次処理し、並行して多数のリクエストを処理できるようになります。

  • if (WebSocketTransformer.isUpgradeRequest(request)) { ... }
    この行では、受信したリクエストがWebSocket接続要求であるかどうかを確認します。WebSocket接続でない場合は、HTTP 403 Forbiddenレスポンスを返してリクエストを拒否します。これにより、通常のHTTPリクエストを拒否し、WebSocket接続のみを受け付けるようにします。

  • WebSocketTransformer.upgrade(request).then(handleWebSocket);
    この行では、WebSocket接続要求を処理し、handleWebSocket関数に接続を引き渡します。WebSocketTransformer.upgradeメソッドは、HTTPリクエストをWebSocket接続にアップグレードし、WebSocketオブジェクトを返します。これにより、クライアントとの双方向通信が可能になります。

  • void handleWebSocket(WebSocket socket) { ... }
    この関数はWebSocket接続を処理します。socketオブジェクトを使用してクライアントとの通信を管理します。この関数内で、クライアントからのメッセージを受信し、適切な応答を返すロジックを実装します。

  • socket.listen( ... )

    • この行では、WebSocketからのメッセージをリスン(監視)し、受信データ、接続終了、エラーの各イベントに対して適切な処理を行います。

    • データ受信時(data):
      クライアントからデータが送信されると、そのデータを受け取り、サーバーがそれに対して何を行うかを記述します。この例では、受信したデータをコンソールに表示し、同じデータをクライアントに送り返します。

    • 接続終了時(onDone):
      クライアントが接続を終了すると、このイベントが発生します。ここでは、接続が終了したことをコンソールに表示します。

    • エラー発生時(onError):
      通信中にエラーが発生すると、このイベントが発生します。ここでは、エラー内容をコンソールに表示します。

以上のロジックにより、WebSocketサーバーが外部からの接続を受け入れ、クライアントとのデータ通信を管理します。このサーバープログラムは、受信したデータをそのまま送り返すエコーサーバーとして動作します。

クライアントのプログラミング

クライアントのプログラムについて説明します。Flutterアプリを作成する前に、まずDartでサーバーが正常に動作するかどうかを確認する必要がありました。そのため、Flutterアプリの作成前にDartのプログラムを作成し、通信を行うことにしました。このプログラムは、私のパソコンのIPアドレスを使用するため、ifconfig(Mac)やipconfig(Windows)を使って自分のPCのIPアドレスに書き換えた上で実行してください。

このクライアントプログラムは、起動後、指定のIPアドレスのWebSocketにメッセージを送信し、その後すぐに終了するシンプルなものです。WebSocketを使用したデータの送信方法が簡単に理解できると思います。
connectWebSocketはFlutterでもそのまま使います。

import 'dart:io';

main() async {
  connectWebSocket('Hello from Dart');
}

void connectWebSocket(String message) async {
  const ipAddress = '192.168.1.33';

  try {
    WebSocket socket = await WebSocket.connect('ws://$ipAddress:5000').timeout(const Duration(milliseconds: 1000));
    print('Connected to WebSocket');
    socket.listen(
      (data) {
        print('Received: $data');
      },
      onDone: () {
        print('Disconnected from WebSocket');
      },
      onError: (error) {
        print('Error: $error');
      },
    );
    // メッセージを送信する例
    socket.add(message);
    socket.close();
  } catch (e) {
    print('Error: $e');
  }
}

クライアントプログラムの詳細について説明します。このプログラムでは、WebSocketのリスナー内にデータ、onDone、およびonErrorの3つのイベントハンドラーがあります。

  • data: サーバーにデータを送信し、サーバーから返されたデータを受け取ります。今回は、受け取ったデータをprintで表示しています。
  • onDone: ソケットが閉じられたときに実行され、ソケットの接続が終了したことを表示します。
  • onError: エラーが発生したときに実行されます。

メッセージの送信はsocket.addを使用して行い、送信後にsocket.closeでソケットを閉じます。これにより、onDoneが実行され、ソケットが閉じられた時の出力がされます。リスナーの設定は、ストリーム処理でよく使われる方法ですが、初心者には少し難しいかもしれません。

Flutterアプリでサーバにアクセス

最後に、Flutterアプリでサーバーにアクセスする方法を説明します。
全ソースは後で記載しますので、ここでは通常のカウントアップアプリとの変更点を記載します。といっても、「+」を押したときにカウンターをあげるだけでなく、先ほどのクライアントアプリと同様の通信処理を行うだけです。

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _incrementCounter();
          connectWebSocket('counter: $_counter');
        },
      ), 
    );
  }
}

実行

  • サーバーの実行: dart socket_server.dart
  • クライアントの実行: dart socket_client.dart

上記でPC内で問題なく通信が行えるのが確認できます。
動作確認後、アプリで実行をしてください。

エラー

わざとIPやポート番号を間違えたとき、どのようなエラーが発生するか確認しました。動作しなかったときの参考にしてください。

TimeoutException after 0:00:01.000000: Future not completed

connectWebSocket 内で発生した場合、指定のサーバに繋がらず、時間切れになったことを意味する。
クライアントプログラムでは、IPアドレスやポート番号が間違えていた、サーバプログラムを起動してなかった
時に発生した。

SocketException: No route to host (OS Error: No route to host, errno = 113), address = 192.168.1.32, port = 39182

Flutterアプリでの実行で発生したときは、IPアドレスが間違えていた。

SocketException: Connection refused (OS Error: Connection refused, errno = 111), address = 192.168.1.33, port = 43302

Flutterアプリでの実行で発生したときは、ポート番号が間違えていた。
プログラムで指定したポート番号と異なる(プログラム:5000, 表示:43302)が、アプリ側のポート番号のため、この番号自体は問題ない(ちなみに実行毎に番号が変わる)。

Q&A

Q1: サーバープログラムを自分のPC上で実行する際の注意点はありますか?
A1: サーバープログラムを実行する前に、自分のPCのIPアドレスを確認し、プログラム内で正しく設定してください。また、ファイアウォールやセキュリティソフトが通信をブロックしないように設定を確認してください。

Q2: サーバーに接続できない場合の対処法は?
A2: サーバーに接続できない場合、以下の点を確認してください。

  • IPアドレスが正しいか
  • ポート番号が一致しているか
  • サーバープログラムが正しく起動しているか
  • ファイアウォールの設定を確認し、必要に応じてポートを開放する

Q3: WebSocketの通信がうまくいかない原因は何ですか?
A3: WebSocketの通信がうまくいかない原因として、以下の点が考えられます。

  • サーバーが正しく起動していない
  • クライアントのIPアドレスやポート番号が間違っている
  • ネットワークの設定やファイアウォールが通信をブロックしている
  • サーバー側で例外が発生している

まとめ

今回は、Flutterを使用してPC上のサーバーと通信を行う方法について紹介しました。サーバープログラムを自分のPC上で実行し、実機のスマホからアクセスすることで、WebSocketを用いた双方向通信の仕組みを学びました。サーバーとクライアントのプログラムを理解し、エコーサーバーとして動作させることで、基本的な通信の流れを確認できたと思います。

これにより、Flutterアプリでのネットワーク通信の基礎を理解し、今後のアプリ開発に役立てることができるでしょう。問題が発生した場合は、エラーやQ&Aセクションを参考にトラブルシューティングを行ってください。

興味を持っていただけた方は、実際にコードを試して、さらに応用的な使い方にも挑戦してみてください。

参考

ソース(main.dartにコピペして動作確認用)

IPアドレスを書き換え、サーバ用のプログラムを実行してから動作させてください。

import 'dart:io';

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: const MyHomePage(title: 'WebSocket Test'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _incrementCounter();
          connectWebSocket('counter: $_counter');
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

  void connectWebSocket(String message) async {
    const ipAddress = '192.168.1.33';

    try {
      WebSocket socket = await WebSocket.connect('ws://$ipAddress:5000');
      print('Connected to WebSocket');
      socket.listen(
        (data) {
          print('Received: $data');
        },
        onDone: () {
          print('Disconnected from WebSocket');
        },
        onError: (error) {
          print('Error: $error');
        },
      );
      // メッセージを送信する例
      socket.add(message);
      socket.close();
    } catch (e) {
      print('Error: $e');
    }
  }
}