2020年8月20日 星期四

採用 Android Kotlin 程式庫的 NSD 網路服務先探

參考資料: https://developer.android.com/training/connect-devices-wirelessly/nsd

linux mint 上針對區域網路的名稱服務協定可以使用 avahi, 透過 avahi-browse -a  -r 就能探聽到區域網路上服務裝置所丟出的服務訊息,從而解析其 ip 位址及所接受的服務端口 port, 他支援常見的名稱服務協定 mDNS/DNS-SD, Android 裝置上使用的 NSD 也支援 mDNS/DNS-SD

這個服務協定原理是:由服務裝置先在區域網路上用 multicast 群組廣播它所服務的名稱類型,區域網路上有興趣者,擷取到該訊息或事先知道訊息就可以透過相同管道去探尋,想辦法獲得該服務裝置的 ip  位址與通信端口 port, 之後通過 ip:port 直接與對方一對一(p2p)通訊, 像這樣透過 p2p (peer to peer)的技術, 讓 app 自成一套網路, 不用伺服器,而讓 app 身兼伺服器角色, 讓ㄧ對多或是多對多通信也不成問題.

 Android 的服務裝置透過 NsdManager 就能完成包含服務公告及探索任務, 使用 kotlin 來寫程式好處是不用像 Java 要寫長長的程式碼, 善用 coroutine 也能完成非同步任務(async task), 首先編輯在 app 底下的 build.gradle, 加入支援 kotlinx-coroutines 的程式庫:
    dependencies {
        implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
        implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9'
        implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'

    }

在程式內也必須引入所用到的相關定義:
    import android.content.Context
    import android.net.nsd.NsdManager
    import android.net.nsd.NsdServiceInfo

    import kotlinx.coroutines.*
    // ...
    val _nsdManager = context.getSystemService(
                Context.NSD_SERVICE) as NsdManager


當 kotlin 的 fun 函式前面冠上保留字 suspend 時, 在裏面就能呼叫別的 suspend 函數(有點類似 javascript 的 function 前冠上 async 就能在函式裏 await 呼叫別的 async function 類似的道理), Kotlin 會將他編譯成一個可以暫停/重啟/毀滅的狀態機(state machine)函式, kotlin 程式庫裏面有一個 suspend fun delay(毫秒單位) 可以用來暫停一段時間同時切換到下一個任務繼續執行, suspend fun 函式一旦呼叫了其它 suspend fun, 函式本身將招致凍結, 等到所呼叫的函式完成後就會再度被喚醒, 用回圈定時輪詢的方式來實現一個 NSD 公告註冊程序:
    private suspend fun nsdRegisterTCP (
         protocol:Int="http",
         servicePort: Int=8080
    ): String {
        val nsdInfo = NsdServiceInfo( ).apply {
            setServiceName("MyPhone") // 名稱自由指定, 有可能會被 android 修改
            setServiceType("_$protocol._tcp") // 協定用 _xxx._xx 方式命名
            setPort(servicePort) // 公告的服務端口 port
            setAttribute("key",  "value") // 其他 TXT, key=value
        } 
        var running: Boolean = true
        _nsdManager.registerService(
              nsdInfo,
              NsdManager.PROTOCOL_DNS_SD,
              object: NsdManager.RegistrationListener {
                    override fun onRegistrationFailed(info: NsdServiceInfo, err: Int) {
                         running = false
                     }
                    override fun onUnregistrationFailed(info: NsdServiceInfo, err: Int) {
                         running = false
                    }
                    override fun onServiceUnregistered(info: NsdServiceInfo) {
                         running = false
                    }
                    override fun onServiceRegistered(info: NsdServiceInfo) {
                         nsdInfo.setServiceName(info.getServiceName())//正式註冊的網路名稱
                         running = false
                    }
              }
        )
        while (running)  { delay(100) }// 暫停 suspend, 100mS 後再回來看是否已完成
        return "$nsdInfo" // 結束後回傳字串
    }

要注意上述 servicePort 是公告所要服務的端口, 與 NSD 本身並不相關, NSD 使用的是群組廣播 multicast 通道, 可以公告多種服務類型, 公告服務單純只用來註冊網路服務, 當公告的名稱一有砥觸,Android 自動會在名稱後面添加一些文字以茲區別,探索服務則是針對服務類型主動發送請求,用以解析對方 ip 與 port, 因此等待時間可能會久一點, 程式邏輯當然也能用上述定時輪詢(pooling)的方式查看旗標狀況來實現, 但這次不使用 suspend fun 函式, 改用一般普通的函式先呼叫 runBlocking 讓裡面的 supspend fun 凍結在該區塊內, 同時藉由 launch 發動 coroutine 所得到的任務名稱 job, 作為將來解析完成後自動把 coroutine 解離, 並加以跳脫 runBlocking 區塊, 因為 runBlocking 會等所有 coroutines join 完成就自動解職離開, 當要探尋多種服務管道時, 這樣的程式運作邏輯, 或許較有效率, 詳細程式碼如下:
    import java.net.InetAddress
    data class IP_PORT(val ip: InetAddress?, val port: Int){ }
    private fun nsdDiscorverHost(
        sniffName: String="name2Find",
        sniffType: String="_http._tcp"
     ) : IP_PORT  {
        if (sniffType.length == 0 || sniffName.length == 0) return IP_PORT(null, 0)
        var sniffHost: InetAddress? = null
        var sniffPort: Int = 0
        lateinit var job: Job
        val nsdLinstener = object: NsdManager.DiscoveryListener {
            override fun onDiscoveryStarted(msg: String) { } 
            override fun onServiceLost(info: NsdServiceInfo) { }
            override fun onDiscoveryStopped(msg: String) {
                if (! job.isCompleted)  job.cancel( )
            }
            override fun onStopDiscoveryFailed(msg: String, err: Int) {
                _nsdManager.stopServiceDiscovery(this)
            }
            override fun onStartDiscoveryFailed(msg: String, err: Int) {
                _nsdManager.stopServiceDiscovery(this)
            }
            override fun onServiceFound(info: NsdServiceInfo) {
                val discovery = this // this is a DiscoveryListener
                if (info.serviceName.contains(sniffName)) {
                      _nsdManager.resolveService(
                           info,
                           object: NsdManager.ResolveListener {
                                  override fun onResolveFailed(resolv: NsdServiceInfo, err: Int) { }
                                  override fun onServiceResolved(resolv: NsdServiceInfo) {
                                       sniffHost = resolv.host
                                       sniffPort = resolv.port
                                       // if (resolv.serviceName == sniffName)
                                       _nsdManager.stopServiceDiscovery(discovery)
                                  }
                          }
                      )
                }
                // ...
            }
        }
        runBlocking { // 最多凍結 10 秒
            job = launch { // get job, launch 無傳回值:  ( )-> void
                  _nsdManager.discoverServices(
                      sniffType,
                      NsdManager.PROTOCOL_DNS_SD,
                      nsdLinstener
                  ) // 呼叫的一般函式並不會被凍結, 裏面持續在運行
                  delay(10000) // 呼叫 suspend 函式時, 本身暫時凍結 10 秒
            } // 一旦 job 被 cancel, 裏面所屬父子代程序也將一併消失
        }
        return IP_PORT(sniffHost, sniffPort)       
    }

主程式如要註冊及探尋網路服務可以用 GlobalScope.launch 同時運行上述兩個 coroutines:
    GlobalScope.launch(Dispatchers.Main) {
        launch {
            val msg  = async {// async 有傳回值: Deferred<String>
                    nsdRegisterTCP("http",  8080) // 這是 async 最後一行函數值
            }
            print("${msg.await( )}")// method Deferred.await( ) to get future value
        }
        launch {
            val find = async {{// async 有傳回值: Deferred<String>
                      nsdDiscorverHost("MyPhone",  "_http._tcp") // 這是 async 最後一行函數值
            }
            print("${find.await( )}")// method Deferred.await( ) to get future value
        }
    }

上面用的  runBlocking, GlobalScope.launch 區塊是在函式內用來發動 coroutine 的工具, 也就是說他是介於一般 routine 與 coroutine 之間運作的橋樑, 當然放在 suspend fun 也是可以的, runBlocking 會凍結函式的運作, 但 GlobalScope.launch 不會, 至於裏面的 launch 及 async 兩種區塊, 他們之間的差異點是: launch 後的 job 不會傳回值, 但 async 區塊可以傳回區塊裏面最後一行的函數值, 發動之後要用方法 await( ) 等待 async 內所有 coroutine 完成後就能取得函數值, 類似 Javascript async/await 的用法, 當 coroutines 放在不同的 launch 區塊時, 他們是獨立並行運作的, 無法斷定何者會先完成, 至於區塊裡面的 coroutines 卻是循序運作的, 而且 runBlocking, GlobalScope.launch, launch , async 等區塊內並不侷限用 suspend fun, 一般方式寫的同步函式也可以放在裏面, 只是它無法被暫停或重置.

最後要再次強調的是 suspend fun 只能由同樣是 suspend fun 類型的函式去呼叫, 或是交由 Kotlin CoroutineScope builder  像是 runBlocking 或 GlobalScope.launch 讓它在區塊範圍內運作

沒有留言:

張貼留言

簡單 c 程式碼, 根據五行八卦相生相剋推斷吉凶

#include "stdio.h" // 五行: //               木2 //      水1           火3 //         金0     土4 // // 先天八卦 vs 五行 //                    ...