システム開発部のTです。
普段はAndroid、iOSなどのネイティブアプリ開発やフロントエンドアプリ開発に携わっています。
皆さん、ネットワークサービスというのをご存知でしょうか。今回はそのネットワーク上に存在するサービスを一網打尽に検索するアプリの作成をしてみようと思います。

ネットワークサービスとは

本件のネットワークサービスとは、ローカルネットワーク(以降はLAN)上で提供している各種デバイスのサービスのことです。たとえば、プリンタサーバもその一つになります。最近のプリンタサーバは、LAN上にサービスとして登録し、クライアントは登録されたサービスを検索していくことで、プリンタサーバの存在を確認することができます。LAN上にサービスとして登録しておくことで、わざわざプリンタサーバ等デバイスのIPアドレスを事前に把握することなく、各デバイスへアクセスすることが可能となります。

今回は、そのLAN上に点在するサービスを検索し、それを画面上に表示できるアプリを作成していこうと思います。

なお、今回はFlutterでアプリを作成しますが、制限の関係でAndroidオンリーとさせてもらいます。

開発環境

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

Android StudioChipmunk | 2021.2.1 Patch 2
Flutter3.0.3
Dart2.17.5

アプリ機能概要

以下の機能を実装します。

  • LAN内サービス検索機能
  • サービスに紐付いたデバイス表示

LAN内のサービスを検索し、それを一覧表示します。
サービス名をタップすることで、次画面に遷移。サービスに紐付いたデバイス名とHost名を表示します。表示された内容をタップすることで、外部ブラウザに遷移し、デバイスのIPアドレスにアクセスを試みます。

最後のブラウザで表示できるかは、IPアドレス先のデバイスにWebサーバーが起動しているか否かで。表示されるとは限りません。

今回は、上記の機能の実装をしていきます。

利用するプラグイン

本件では、ネットワークサービスディスカバリ(以降NSD)という機能を利用するため、以下のプラグインを利用します。

https://pub.dev/packages/nsd

準備

プロジェクトの生成

このへんは特に気にするところはなく、普通に作っていきます。
最後に「Finish」ボタンを押下してください。

プロジェクト生成後の画面です。

プラグインのインストール

pubspec.yamlを開き、以下の内容を追記後、「Pub get」を実行してください。

dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  nsd: ^2.2.3                      # <- 追加
  url_launcher: ^6.1.5     # <- 追加
  fluttertoast: ^8.0.9     # <- 任意(エラー時に表示するため)

追加後は以下の画面のイメージになっているかと思います。
追加後、画面上の`Pub get`を実行してください。

Android側の設定

AndroidManifestファイルを開き、以下のPermissionを追加します。

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

以下のイメージで追加したことを確認してください。

以上で設定は完了です。
引き続き実装に入ります。

実装・・・の前に

プロジェクト作成直後、全ての処理がmain.dartに固まっているため、機能ごとにファイルを分けます。

import 'package:flutter/material.dart';
import 'package:sample_nsd_app/my_app.dart';

void main() {
  runApp(const MyApp());
}
import 'package:flutter/material.dart';
import 'package:sample_nsd_app/my_home_page.dart';

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}
import 'package:flutter/material.dart';

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

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

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('LAN内サービスタイプ検索一覧'),
      ),
      body: Container()
    );
  }
}

ここまで、ファイル分割、余計なコメント削除、最小限の処理にまとめました。
ここから実装に入ります。

LAN内サービスタイプ検索一覧画面の実装

LAN内に点在しているネットワークサービスを検索した結果を一覧として表示する画面を実装していきます。一覧には、サービス名、サービスタイプを表示します。
データ取得には、以下を参考にしました。

https://pub.dev/packages/nsd#discover-all-services-of-all-types-on-local-network

では、上記を踏まえてサービスの内容を取得していきましょう。
以下、ざっくりと書いていきます。

() async {
    final discovery = await startDiscovery('_services._dns-sd._udp', autoResolve: false);
    discovery.addListener(() {
        for (var service in discovery!.services) {
            // ここで以下の内容のサービス内容を取得する
            // {service.type: _tcp.local, service.name: _bar, ...}
        }
    });
}()

startDiscovery()で検索スタートします。
本来startDiscovery()の引数にはサービスタイプ(例えば_http._tcp等)を設定することで、設定したサービスタイプに紐づくデバイスを検索しますが、引数に_services._dns-sd._udpと設定することで、LAN上のサービス内容を検索するようになります。

startDiscoveryからはDiscoveryのインスタンスを取得できるので、Discoveryに対してaddListenerにてコールバックを登録しておくと、検索結果取得時にコールバックでサービス内容を参照可能になります。

本件にてServiceから取得できる内容はservice.type、およびservice.nameからのみとなります。
上記コード上のコメントにも示しているとおり、以下の内容が入ってきます。

{service.type: _tcp.local, service.name: _bar, ...}

service.nameに入っている内容は、ずばりサービス名そのものです。
service.typeに入っている内容については、実際のサービスタイプではなく、通信プロトコルです。
実際のサービスタイプについては、上記で取得したサービス名+サービスタイプから.local文字列を削除した内容となります。つまり・・・、

_bar._tcp

上記が実際のサービスタイプとなります。
上記で取得したサービスタイプの文字列を、さらにstartDiscoveryの引数に設定して検索かけることで、それに紐付いたデバイス情報を取得できるのですが、その話は後ほどとさせてください。

ここまでで、サービス情報の検索手段はご理解されたかと思うので、以下にて実際の実装を見ていただければと思います。

my_home_page.dartへの実装

import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:nsd/nsd.dart';
import 'package:sample_nsd_app/service_list_page.dart';

class _ServiceTypeDataItem {
  var type = "";
  var name = "";
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

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

class _MyHomePageState extends State<MyHomePage> {

  List<_ServiceTypeDataItem> dataList = [];
  Discovery? discovery;

  @override
  void initState() {
    super.initState();
    startDiscovery('_services._dns-sd._udp', autoResolve: false).then((discovery) {
      this.discovery = discovery;
      discovery.addListener(listener);
    }, onError: (error) {
      Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
    });
  }

  @override
  void dispose() {
    if (discovery != null) {
      discovery!.removeListener(listener);
      stopDiscovery(discovery!);
    }
    super.dispose();
  }

  void listener() {
    if (discovery != null) {
      for (var service in discovery!.services) {
        var dataSource = _ServiceTypeDataItem();
        dataSource.type = '${service.name}.${service.type?.replaceAll('.local', '')}';
        dataSource.name = service.name ?? '';
        final isNotEmpty = dataList.where((data) {
          return (data.type == dataSource.type && data.name == dataSource.name);
        }).isNotEmpty;
        if (!isNotEmpty) {
          dataList.add(dataSource);
        }
      }
      setState((){});
    }
  }

  @override
  Widget build(BuildContext context) {
    dataList.sort((a,b) => a.name.compareTo(b.name));
    return Scaffold(
      appBar: AppBar(
        title: const Text('LAN内サービス検索一覧'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            itemCount: dataList.length,
            itemBuilder: (context, index) {
              var e = dataList[index];
              return ListTile(
                title: Text('サービス名:${e.name}'),
                subtitle: Text('サービスタイプ:${e.type}'),
              );
            },
          ),
        ],
      ),
    );
  }
}

以降、主要な処理をピックアップします。

initState()

  @override
  void initState() {
    super.initState();
    startDiscovery('_services._dns-sd._udp', autoResolve: false).then((discovery) {
      this.discovery = discovery;
      discovery.addListener(listener);
    }, onError: (error) {
      Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
    });
  }

initStateにて、startDiscoveryしています。
discovery取得失敗時はToastでエラー表示していますが、こちらは任意で。
登録するlistenerは43行目に定義しています。

dispose()

  @override
  void dispose() {
    if (discovery != null) {
      discovery!.removeListener(listener);
      stopDiscovery(discovery!);
    }
    super.dispose();
  }

disposeで、widget終了後の後始末を記載しています。
nsdプラグインの公式ページにもシレェ〜っと記載されているのですが、stopDiscoveryは必須です。
これをしないと、無駄なdiscoveryが増え続けていき、最終的に以下の例外が発生します。

Unhandled Exception: NsdError (message: "Maximum outstanding requests reached", cause: maxLimit)

listener()

  void listener() {
    if (discovery != null) {
      for (var service in discovery!.services) {
        var dataSource = _ServiceTypeDataItem();
        dataSource.type = '${service.name}.${service.type?.replaceAll('.local', '')}';
        dataSource.name = service.name ?? '';
        final isNotEmpty = dataList.where((data) {
          return (data.type == dataSource.type && data.name == dataSource.name);
        }).isNotEmpty;
        if (!isNotEmpty) {
          dataList.add(dataSource);
        }
      }
      setState((){});
    }
  }

discoveryに登録するコールバックメソッドです。
discovery.servicesから各種サービス情報を取得できるので、各データを_ServiceTypeDataItem上に保持し、dataListに追加します。
最後にsetStateさせることで画面再描画が走り、画面上に一覧表示していきます。

listenerは何度もコールされるため、dataListに重複で追加されないように重複チェック処理を入れています。

build()

  @override
  Widget build(BuildContext context) {
    dataList.sort((a,b) => a.name.compareTo(b.name));
    return Scaffold(
      appBar: AppBar(
        title: const Text('LAN内サービス検索一覧'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            itemCount: dataList.length,
            itemBuilder: (context, index) {
              var e = dataList[index];
              return ListTile(
                title: Text('サービス名:${e.name}'),
                subtitle: Text('サービスタイプ:${e.type}'),
              );
            },
          ),
        ],
      ),
    );
  }

dataListをサービス名順にソートし、画面のUIに反映しています。

ここまで実装し、以下が実際の画面になります。

いかがでしょうか。
私の部屋にはいろいろな機器があるので、Iot的なものも複数表示されていますね。
ただ、上記だけだとサービス名のみで、どういうデバイスなのか不明なので、次の実装ではサービス名に紐付いたデバイスを表示する機能を実装します。

デバイス検索結果一覧画面の実装

先程の画面にて、サービスタイプが取得できたかと思いますが、今度はそのサービスタイプの文字列を引数としてstartDiscoveryします。

以下、実装の参考にした公式ページです。

https://pub.dev/packages/nsd#basic-usage

新たにdevice_list_page.dartファイルを追加します。
以下、実際の実装内容です。

device_list_page.dartの実装

import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:nsd/nsd.dart';
import 'package:url_launcher/url_launcher.dart';

class DeviceListPage extends StatefulWidget {

  final String serviceType;
  const DeviceListPage({Key? key, required this.serviceType}) : super(key: key);

  @override
  State<DeviceListPage> createState() => _DeviceListPageState();
}

class _DeviceDataItem {
  var type = "";
  var name = "";
  var host = "";
  var port = 0;
}

class _DeviceListPageState extends State<DeviceListPage> with RouteAware {

  List<_DeviceDataItem> dataList = [];
  Discovery? discovery;

  @override
  void initState() {
    super.initState();
    startDiscovery(widget.serviceType).then((discovery) {
      this.discovery = discovery;
      discovery.addServiceListener(listener);
    }, onError: (error) {
      Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
    });
  }

  @override
  void dispose() {
    if (discovery != null) {
      stopDiscovery(discovery!);
      discovery?.removeServiceListener(listener);
      discovery?.dispose();
    }
    super.dispose();
  }

  void listener(service, status) {
    if (status == ServiceStatus.found) {
      var dataSource = _DeviceDataItem();
      dataSource.type = service.type ?? '';
      dataSource.name = service.name ?? '';
      dataSource.host = service.host ?? '';
      dataSource.port = service.port ?? 0;
      final isNotEmpty = dataList.where((data) {
        return (data.type == dataSource.type && data.name == dataSource.name && data.host == dataSource.host && data.port == dataSource.port);
      }).isNotEmpty;
      if (!isNotEmpty) {
        dataList.add(dataSource);
      }
      setState((){});
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('[${widget.serviceType}]一覧'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            itemCount: dataList.length,
            itemBuilder: (context, index) {
              var e = dataList[index];
              return ListTile(
                title: Text(e.name),
                subtitle: Text('Host:${e.host}${e.port == 0 ? '' : ':${e.port}'}'),
                onTap: () {
                  if (e.port == 0) {
                    _launchUrl('http://${e.host}');
                  } else {
                    _launchUrl('http://${e.host}:${e.port}');
                  }
                },
              );
            },
          ),
        ],
      ),
    );
  }

  Future<void> _launchUrl(String urlText) async {
    Uri url = Uri.parse(urlText);
    await launchUrl(url, mode :LaunchMode.externalApplication);
  }
}

必要な箇所をピックアップします。

DeviceListPageのコンストラクタ

class DeviceListPage extends StatefulWidget {

  final String serviceType;
  const DeviceListPage({Key? key, required this.serviceType}) : super(key: key);

  @override
  State<DeviceListPage> createState() => _DeviceListPageState();
}

サービスタイプの文字列を必須の引数として追加しています。

initState()

  @override
  void initState() {
    super.initState();
    startDiscovery(widget.serviceType).then((discovery) {
      this.discovery = discovery;
      discovery.addServiceListener(listener);
    }, onError: (error) {
      Fluttertoast.showToast(msg: error.toString(), toastLength: Toast.LENGTH_LONG);
    });
  }

DeviceListPageコール時に受領するサービスタイプを引数として、startDiscoveryを実行。
前画面同様にlistenerを登録しますが、今回はaddServiceListenerに対して登録します。

dispose()

  @override
  void dispose() {
    if (discovery != null) {
      stopDiscovery(discovery!);
      discovery?.removeServiceListener(listener);
      discovery?.dispose();
    }
    super.dispose();
  }

前画面同様、画面終了時の後始末も実装。
stopDiscoveryは忘れないように!

listener()

  void listener(service, status) {
    if (status == ServiceStatus.found) {
      var dataSource = _DeviceDataItem();
      dataSource.type = service.type ?? '';
      dataSource.name = service.name ?? '';
      dataSource.host = service.host ?? '';
      dataSource.port = service.port ?? 0;
      final isNotEmpty = dataList.where((data) {
        return (data.type == dataSource.type && data.name == dataSource.name && data.host == dataSource.host && data.port == dataSource.port);
      }).isNotEmpty;
      if (!isNotEmpty) {
        dataList.add(dataSource);
      }
      setState((){});
    }
  }

こちらも処理内容は前画面のlistenerと変わりないのですが、引数にservice、statusがあるのでstatusの判定にて存在していれば画面上に表示するデータを設定するようにしています。

seivice.typeはサービスタイプ、service.nameはデバイス名、service.hostはホスト名(IPアドレス)、service.portは実際にアクセスするためのポート番号が入ってきます。

もちろん、listenerは何度もコールされるため、データの重複を防ぐための重複チェック処理も入れました。

最後にsetStateすることで画面に反映されます。

build()

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('[${widget.serviceType}]一覧'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            itemCount: dataList.length,
            itemBuilder: (context, index) {
              var e = dataList[index];
              return ListTile(
                title: Text(e.name),
                subtitle: Text('Host:${e.host}${e.port == 0 ? '' : ':${e.port}'}'),
                onTap: () {
                  if (e.port == 0) {
                    _launchUrl('http://${e.host}');
                  } else {
                    _launchUrl('http://${e.host}:${e.port}');
                  }
                },
              );
            },
          ),
        ],
      ),
    );
  }

  Future<void> _launchUrl(String urlText) async {
    Uri url = Uri.parse(urlText);
    await launchUrl(url, mode :LaunchMode.externalApplication);
  }

最後はUI描画処理です。
listenerで取得したデバイス情報を一覧表示します。
一覧には、デバイス名と、デバイスのアクセス先でもあるホスト名、ポート番号を表示しました。
また、任意の内容をタップすることで、外部ブラウザでアクセスを試みるようにしております。
(http://〜でアクセスするようにしていますが、必ずしもブラウザ上に表示するとは限りません。)

my_home_page.dartに画面遷移処理追加

  @override
  Widget build(BuildContext context) {
    dataList.sort((a,b) => a.name.compareTo(b.name));
    return Scaffold(
      appBar: AppBar(
        title: const Text('LAN内サービス検索一覧'),
      ),
      body: Stack(
        children: [
          ListView.builder(
            itemCount: dataList.length,
            itemBuilder: (context, index) {
              var e = dataList[index];
              return ListTile(
                title: Text('サービス名:${e.name}'),
                subtitle: Text('サービスタイプ:${e.type}'),
                onTap: () {
                  Navigator.push(context, MaterialPageRoute(builder: (context)=>DeviceListPage(serviceType: e.type))); // 画面遷移処理追加
                },
              );
            },
          ),
        ],
      ),
    );
  }

上記のようにListTileonTapを追加し、押下イベント処理を追加します。
押下時に、Navigator.pushを実行し画面遷移するようにしてください。

実際の画面イメージは以下となります。

上記の場合、_http._tcpというサービスタイプで検索した結果が表示されています。
一覧をタップした場合、アクセス先がWebサーバーの場合、ブラウザ上には以下のように画面が表示されるはずです。

以上ですべての実装が終了です。

最後に

いかがだったでしょうか。
私自身、ネットワークサービス関連の開発に関わったことはなく、今回担当案件で勉強する必要があったので、どういったものなのかを理解する過程で本件の記事を書いてみました。

私自身も実装してみて、実際に動かしてみたところ、自分の知らないところでいろいろなサービスが動いているのだと改めて知ることができました。

自分の身の回りにどういうサービスが動いているのか、知りたい方はぜひ今回の実装を試していただければと思います。

本件では、ネットワーク上のサービスを検出する機能の説明でしたが、今回のnsdプラグインにはネットワーク上にサービスを登録する機能もあるので、次回はサービス登録機能についても書いていければと思います。



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