iPhoneアプリ開発 ネットワークプログラミング (2)

作成日:2021/05/11

お役立ちコラム

iPhoneアプリ開発 ネットワークプログラミング (2)

前回に続きiOSネットワークプログラミングの基礎を解説します。
今回は Bonjour (ボンジュール)を紹介します。
Bonjour は Apple による Zeroconf (IETF Zero Configuration Networking) の実装で、DNSなどの設定なしにMac、iPhone、プリンターなどの機器同士を接続できます。
Bonjour の主な機能は 1) IPアドレスの自動割り当て (DHCPサーバーがなくても)、2) 名前解決 (DNSサーバーがなくても)、3) サービスの検出 です。

Bonjour は Apple による実装ですから Mac や iPhone で標準的に動作しますがライセンスは Apple が持っています。
ライセンス問題を回避するため、Apache License 規約で提供されるオープンソースの Avahi という Zeroconf の実装もあります。
Avahi は Linux 上に実装されているため、プリンターからテレビに至るまでさまざまな機器で動作します。
Windows は長らく Zeroconf に対応しておらず、Apple の Bonjour をインストールするしかありませんでしたが、バージョン1803以降の Windows 10 は OS に Zeroconf の mDNS(後述)を組み込んでいます。
Android OS は Network service discovery (NSD) という API を提供しています。
そのためアプリで名前解決とサービスの検出ができますが、現時点では多くのアプリは mDNS に対応していません。
以降は Apple の Bonjour について解説します。

IPアドレスの自動割り当て

DHCPが動作していない場合、Bonjourにより169.254.0.0/16の中から空いているアドレスを探し自身に割り当てます。
これはmacOSやiOSで標準的に実行されますから、アプリのネットワークプログラミングで意識する必要はありません。

名前解決

DNSサーバーがない場合、あるいはローカルの機器でDNSに名前が登録されていない場合、BonjourはmDNS (multicast DNS)プロトコルにより名前解決(ホスト名からIPアドレスの取得)を行います。
問い合わせる側はホスト名をマルチキャストする(mDNS Query)。
答える側は自身のホスト名を受け取ったら、自身のIPアドレスを返す(mDNS Resource)というシンプルな仕組みです。
ローカルの(同じセグメント内の)ホスト名には .local を付けます

例:
$ ping myMac.local

ネットワークプログラミングでもローカルのホスト名には .local をつける以外には特に意識する必要はありません。

例:
if var urlComponents = URLComponents(string: "https://webserver.local/search") {
 …
}

ただし、次に紹介するサービスの検出でも名前解決を行いますので、そこではコーディングが必要です。

サービスの検出

同じくmDNSプロトコルを利用して、httpやprinterなどのサービスを検索して検出できます。
Macにはdns-sdというmDNSサービス検出のテストツールがあります。サービスはサービス名とプロトコルを指定して以下のように検索して検出できます。
$ dns-sd -B _サービス名._プロトコル.
サービス名とプロトコルの一覧は下記のIANAのページで参照できます。

例:
$ dns-sd -B _http._tcp.
$ dns-sd -B _printer._tcp.

以下にサービスの検索を行うiOSプログラミングを解説します。
サービスを検索して検出できれば、iPhoneアプリから自動的に目的のサーバーに接続したり、ユーザーが選択できるように接続先の候補を表示したりできます。

Local Network Privacy

プライバシー保護機能の強化の一環として、iOS 14からBonjourでサービス検出をするには、LANへのアクセスにユーザーの承認が必要になりました。
WWDC20のセッション動画でわかりやすく解説されていますのでご覧ください。
日本語の字幕もあります。

前回はApp Transport Security (ATS)を無効にするためにinfo.plistにキーを追加して値を変更する方法を紹介しました。
Bonjourを使うアプリのプロジェクトを作ったら、前回同様、Xcodeでinfo.plistを表示し、+ボタンをクリックして

Privacy – Local Newtork Usage Description

というキーを追加してください。
そしてその値にLANへのアクセスを求めるメッセージを記入してください。
次に

Bonjour services

を追加し、その配下の item 0 に検索するサービス名とプロトコル(例:_http._tcp.)を記入してください。

サービス検出の開始

(1) サービス検出を開始するには、検出を行うクラスに NetServiceBrowser クラスのオブジェクトを生成します。
  下の例では ViewController クラスに NetServiceBrowser の変数を宣言しました。

(2) 検出したサービスを追加できるように NetService クラスの配列を作成します。

(3) サービスを検出した際のデリゲートを受け取れるよう、ViewController クラスに NetServiceBrowserDelegate と NetServiceDelegate を継承する必要があります。

class ViewController: UIViewController, NetServiceBrowserDelegate, NetServiceDelegate {  // (3)
var nsb: NetServiceBrowser!  // (1)
var services = [NetService]()  // (2)

func startBrowsing() {
self.nsb = NetServiceBrowser()  // (4)
self.nsb.delegate = self  // (5)
self.nsb.searchForServices(ofType:"_http._tcp.", inDomain: :"")  // (6)
}

(4) NetServerBrowser オブジェクトを生成します。

(5) デリゲート先をViewControllerクラス自身(self)に設定します。

(6) searchForServices() 関数の第1引数は_サービス名._プロトコル.(例:"_http._tcp.")、第2引数はサービスを検索するドメイン名です。
  空白("”)を指定すればデフォルトで登録されているドメインを検索します。searchForServices() 関数をコールした時点でサービス検出を開始するため、iPhoneはLANへの接続を求めるメッセージを表示します。

アプリが起動した直後にこのメッセージが表示されると、ユーザーは不審に思うかもしれません。
そのため、最初に起動される ViewController の viewDidLoad()でstartBrowsing() をコールするよりも、実際にサービス検出が必要になったタイミングでコールする方がユーザーにとって関連性がわかりやすいでしょう。

サービスの検出

サービスが検出されると NetServiceBrowserDelegate により、netServiceBrowser がコールバックされます。

func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
self.services.append(service)  // (7)
if !moreComing {  // (8)
self.resolve()
}
}

(7) 受け取ったサービス(service)をサービスの配列(services)に追加します。

引数のmoreComingがtrueであればさらに追加サービスを待っています。
この時点でサービス名(service.name)は得られていますが、まだ名前解決は完了していません。
名前の解決はservice.resolve()関数をコールする必要があります。
ここでサービス1件ずつコールしても良いのですが、すべての受信したサービスをチェックして名前解決が完了していないサービスに対してresolve()をコールする関数を作ります。

(8) moreCommingがfalseであれば、全てのサービスを受信済みですからresolve()をコールします。

func resolve() {
for service in self.services {
if service.port == -1 {   // (9)
service.delegate = self
service.resolve(withTimeout:10)
}
}
}

(9) Service.port が -1 なら、まだ名前解決が完了していないので、service.resolve() をコールします。
  引数はタイムアウト値(秒)です。
  先ほどと同様にデリゲート先は ViewController クラス自身 (self)に設定します。

名前解決が完了すると、NetServiceDelegate により netServiceDidResolveAddress() 関数がコールバックされます。

func netServiceDidResolveAddress(_ sender: NetService) {
print("netServiceDidResolveAddress() name = ", sender.name)
}

この時点でサービス検出は完了したので、NetService 型の sender オブジェクトからドメイン名(sender.domain)、ホスト名(sender.hostName)、ポート番号(sender.port)などの情報を取り出すことができます。

なおself.nsb.searchForServices()で開始したサービスの検索は、self.nsb.stop()で停止できます。

サービス情報の取得

netServiceDidResolveAddress() 関数の中で、ユーザー定義クラスの配列に解決したサービスの情報を1件ずつコピーしても良いのですが、services配列にすべて残していましたので、一括でこちらから取得することもできます。
以下はその例です。

func printServices() {
for service in self.services {
print("")
print("name = ", service.name)
print("domain = ", service.domain)
print("port = ", service.port)
print("type = ", service.type)
print("hostName = ", service.hostName ?? "unknown")
}
}

実際にプログラムを動かして、print() によるデバッグ出力を見てみましょう。

netServiceDidResolveAddress() name = nginx
netServiceDidResolveAddress() name = Synology

2つのHTTPサービスが検出されました。
その後、上記の printServices() 関数をコールすると次のようデータが格納されます。

name = Synology
domain = local.
port = 5000
type = _http._tcp.
hostName = Synology.local.

name = nginx
domain = local.
port = 80
type = _http._tcp.
hostName = raspberrypi.local.

2つのHTTPサーバーのホスト名とポート番号が取得できましたので、いずれのサーバーにもHTTPで接続可能になりました。
接続にはホスト名があればIPアドレスは必要ありませんが、アプリでホスト名とIPアドレスを列記したい場合もあるでしょう。
IPアドレスは service.addressにNSDataオブジェクトの配列として入っています。
しかし、これを192.168.0.15 (IPv4)やfe80::62e2:95c:3b01:d974 (IPv6)のような文字列に変換するのは少しやっかいです。
下記にいくつかデコードの例がありますので、必要な方は参考になさってください。

マルチブラウザー

ひとつのNetServiceBrowserオブジェクトで複数の種類のサービス(例えばHTTPとPrinter)を同時に検索することはできません。
複数の種類のサービスを検索したい場合は複数のNetServiceBrowserを作成する必要があります。
それをマルチブラウザーと呼びます。

以下はHTTPとPrinterの両方を検索する例です。info.plistのBonjour servicesにサービス名を追加するもの忘れないでください。

var nsbh, nsbp: NetServiceBrowser!

self.nsbh = NetServiceBrowser()
self.nsbh.delegate = self
self.nsbh.searchForServices(ofType:"_http._tcp.", inDomain: "")

self.nsbp = NetServiceBrowser()
self.nsbp.delegate = self
self.nsbp.searchForServices(ofType:"_printer._tcp.", inDomain: "")

検出されたサービスは、NetServiceオブジェクトのtypeプロパティで区別できます。

func netServiceDidResolveAddress(_ sender: NetService) {
print("netServiceDidResolveAddress() name_= ", sender.name)
if sender.type == "_http._tcp." {
print("is HTTP")
} else if sender.type == "_printer._tcp." {
print("is Printer")
}
}

マルチブラウザー

前の例では data には画像が入っていましたので UIImage オブジェクトに変換しましたが、クエリーの戻り値に応じて String などに変換してください。
戻り値が XML や JSON などの構造を持つ場合は data を解析する必要があります。
自社のコードで解析をしても良いですが、XML の構文解析には XMLParser が用意されています。

まとめ

2回連続で iPhone のネットワークプログラミングについて解説しましたが、いかがでしたでしょう?
Bonjour を使えば iPhone アプリが接続するサービスが動作しているサーバーを検出できます。
ユーザーがホスト名やIPアドレスを入力する手間を省けますので、ユーザビリティーが向上します。
市販の書籍で Bonjour の API の使い方を解説したものはあまり見かけませんので、お役に立てたようでしたら幸いです。

お問い合わせ

Xcodeでの開発用にはMacレンタルをご利用ください。

追加で開発用機材が必要な時にご利用ください。

お気軽にお問い合わせください

ページの先頭に戻る