網路基礎-網址篇
-
什麼是URL ?
早期的程式語言如C或C++,寫網路程式是一件相當麻煩的事,如今用Swift寫網路程式,相對來說容易多了,幾乎就跟使用瀏覽器一樣簡單。
我們曾經在第13課提到下圖的概念:
右下角的使用者透過手機的瀏覽器或App連到網路上的主機(Host, 或稱為 Server 伺服器),這樣的基本架構稱為「Client-Server 模式」,Client 是指使用者(或稱客戶端)的App,Server 是指主機端的軟體。
Client-Server 模式的連線方式有好幾種,其中最基本的一種可簡化為兩個步驟:
(1) 由Client 主動發出請求(Request)訊息給Server,表明需要什麼資源或內容
(2) Server 根據請求去搜尋資料庫或檔案,然後回應(Response)內容給 Client這樣的過程可稱為請求-反應(Request-Response)。可別小看這兩個簡單步驟,這是模擬低等動物(像水螅)的神經反應,也就是刺激-反應(Stimulus-Response),雖然簡單,但應用在電腦網路卻很有威力,是現在所有網站的主流方式。
Swift網路程式也採用請求-反應(Request-Response),先從App 發出一個「網路請求(Request)」到遠端伺服器(網站主機),伺服器根據請求回覆結果,內容統稱為「網路回應(Response)」,圖示如下:
App想要發出「網路請求(Request)」,有一個很關鍵的資訊稱為 "URL",原文是 Universal Resource Locator,字面上可譯為「通用資源定位器」,其實就是所謂的「網址」,網址並不只有伺服器位址,還包括其他部分,以"2021TaiwanBirds.json" 所分享的網址為例,可分成4個部分,圖解如下
第①部分是網路資源的種類,術語稱為 Scheme (格式、規格、規劃、方案...很多含義),原先這部分稱為網路協定(Network Protocol),最常見是 HTTP 或 HTTPS,HTTP 的連線過程沒有加密,比較節省CPU資源,HTTPS 則會加密,比較安全。
Scheme除了 HTTP/HTTPS 之外,還有 ftp, file, mailto, sms 等等3百多種,幾乎涵蓋目前網路能提供的服務或資源類別。
其中file 是用來存取本機(而非網路上的)檔案,這是為什麼2-7課讀取JSON檔案時,物件的方法(函式)稱為 url()的原因:
let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "json")
還有在疫情期間的實聯制,用手機掃描QR Code之後,會轉到傳送簡訊畫面,其實就是利用 sms (簡訊)的URL網址。
可見 URL 雖然稱為「網址」,但未必都是在瀏覽器中使用,別的地方也會用到,的確可稱為通用(Universal)。
第②部分是主機(Host)位置,術語稱為Domain Name(主機的網域名稱),是代表網路上提供資源的主機地址。
這部分根據不同的資源類型(Scheme),會有不同的語法,例如簡訊 sms:1922,主機位址寫的是電話號碼。或是電子郵件 mailto:heman@sancode.org.tw 寫的是 email address。
第①、②部分的字母是不區分大小寫的,用大寫或小寫字母都代表同樣意思,但是都不允許空格或某些標點符號。
第③部分是或程式的路徑(Path),路徑(子目錄)的分隔用斜線 "/"。在此例路徑最後的 view (檢視)是查詢資料庫的程式或指令。
第④部分是給網頁檔案名稱。
第③、④部分的大小寫是有區別的,不可混淆。
除此之外,在Swift程式語言中,對於網址分別對應的URL物件還有提供 scheme, host, path, query 等4個屬性。
以下第1個範例程式就先練習取用 Swift 的 URL 物件,只需導入 Foundation 物件庫。
操作步驟如下:
- 選取畫面左方的「+」號新增電子書頁面。
- 將新增的電子書面頁命名為「(17)網路程式基礎-1網址篇」。
- 在「Main」模組中撰寫程式:
// 3-1a URL // Created by Philip, Heman, Jean 2021/09/04 // Revised by Jean 2024/11/01 import Foundation let 網址 = "https://joc.url.tw/Swift36/2021TaiwanBirds.json" let myURL = URL(string: 網址)! print("myURL:", myURL, "\n----") print("scheme:", myURL.scheme) print("host:", myURL.host) print("path:", myURL.path) print("query:", myURL.query)
- 程式執行結果,如下圖。
什麼是 URLSession 網路連線?
前一節提到了Swift網路程式採用 Client-Server模式,連線過程分為請求-反應(Request-Response) 兩步驟,完成這兩步驟,也就是完成一次連線過程,在 Swift 中稱為一個「工作(Task)」,而一個或多個工作合起來稱為工作階段或任務(Session)。
Swift 負責網路連線的物件稱為 URLSession (連線任務),每個 URLSession 可產生一個或多個連線工作(Task),每個連線工作(Task)都會進行請求(Request)跟反應(Response)兩個步驟,兩步驟都完成,這個Task才算成功,而所有Task完成,URLSession才算完成。
URLSession基本的用法如以下範例3-1b,這個範例可以正常執行,但是URLSession 任務不會完成,所以請特別注意執行所輸出到主控台的結果。
3-1b URLSession (NG)
- 在「Main」模組中撰寫程式:
// 3-1b URLSession (NG) // Created by Philip, Heman, Jean 2021/09/04 // Revised by Jean 2024/11/01 import Foundation let 網址 = "https://joc.url.tw/Swift36/2021TaiwanBirds.json" let myURL = URL(string: 網址)! URLSession.shared.dataTask(with: myURL) { data, response, error in print(data) print(response) print(error) }.resume() print("Done")
- 程式執行結果,如下圖。
程式中有4個print()輸出指令,但只出現第④個輸出結果,而且沒有任何錯誤訊息,也就是說,語法是正確的,網址也確認沒錯,然而①到③ print()根本沒有執行,為什麼會這樣?
這是因為網路連線程式與前面兩單元所教程式的運作方式很不一樣,什麼地方不一樣呢?我們先看 URLSession 的基本語法:
URLSession.shared.dataTask(with: myURL) { data, response, error in <匿名函式> }.resume()
這裡用到匿名函式,還記得在第2單元2-6課說明過匿名函式,使用匿名函式的物件(如2-6課的父視圖)會負責傳遞參數給匿名函式。在本範例中,由URLSession.shared.dataTask() 負責傳遞參數給匿名函式,傳遞的參數共有3個,名稱可以隨便取,但是用途是固定的,依次分別是:
data -- Response 回傳的資料內容
response -- Response 的回應表頭Headers (或稱為回應碼)
error -- 如果任務中斷,或是伺服器發生錯誤,會傳回錯誤碼回應(Response)拆成兩部分,即資料內容(data)與回應碼(response)。如果Response回傳成功,error 參數會是 nil;如果沒收到 Response,error 會包含錯誤代碼,而 data 與 response 則是 nil。
我們想要看看 data, response, error 的內容,因此在匿名函式中,分別用 print() 列印出來。如果print()參數是nil (未初始化),執行時會產生錯誤訊息而立刻退出,如果是空字串,至少會換行(空白行),但是都沒有,表示根本沒有執行。
為了解釋這個現象,我們需要進一步了解URLSession 的執行過程,圖解如下:
⑴首先,URLSession 物件有個「類別屬性」(type property),稱為 shared (共享)的物件實例,URLSession.shared 語法類似 Color.red,我們在第2單元2-5課說明過「類別屬性」。
URLSession.shared 物件實例有何用途呢?這是指作業系統的一個共享空間(在記憶體中),可以存放所有App提出的網路連線任務,因為所有的網路連線,實際上都是由作業系統統籌管理與執行的。所以 shared 就是使用共享連線任務空間的一個物件實例。
如果不想用共享的網路連線任務,可以用 URLSession(configuration: .default) 產出一個連線任務實例,作業系統會單獨分配一個專屬的記憶體空間給程式使用,在傳輸較大檔案時會較有效率。如果需要,程式碼可以改為:
URLSession(configuration: .default).dataTask(with: myURL) { data, response, error in <匿名函式> }.resume()
如此一來,雖然還是要交給作業系統負責實際連線,但不會和其他App共用任務空間。但就本範例來說,兩者效果是一樣的,所以我們用 URLSession.shared 即可。
⑵有了 URLSession.shared 任務實例之後,就可以呼叫產生「請求工作」的函式,目前共有5個方法可以產生不同的網路請求:
1. dataTask() -- 一般較短暫的資料下載
2. downloadTask() -- 較長時間的檔案下載
3. uploadTask() -- 上傳資料
4. streamTask() -- 音樂、語音或影片的串流資料下載
5. webSocketTask() -- 雙向(上傳、下載)資料交換這5個方法都需用URL當作參數,也都會產出一個代表請求工作的物件實例(即請求-反應Request-Response過程的步驟一),但這時候還沒有真正連線。
⑶等到呼叫這個工作物件實例的 resume(),作業系統才會開始進行網路連線。
所以執行上述⑴⑵⑶之後,才算完成網路連線的第一個步驟(Request):
URLSession.shared.dataTask(with: myURL) { 匿名函式 }.resume()
⑷那麼中間的{ 匿名函式 } 做什麼用呢?最特別的地方就在這裡,這個匿名函式並不會馬上執行,要等到第二個步驟 Response 成功回傳之後,才會帶著3個參數進入匿名函式開始執行。
所以這個匿名函式術語稱為 "completion handler",主要目的就是在請求-反應(Request-Response)兩步驟都完成後,處理回傳資料,至於如何處理回傳資料,當然就是程式設計師的責任啦。
因此,dataTask() 執行網路連線的兩個步驟 Request-Response,可以圖解如下:
URLSession網路連線和其他指令不一樣的地方,就是執行過程的⑶、⑷之間(也就是Request-Response之間)並不連續,不是做完⑶馬上就執行⑷,因為在發出Request之後,什麼時候會得到Response並不知道,有可能很快,也可能永遠回不來(伺服器當機或網路中斷)。
所以當做完⑶,也就是呼叫resume()之後,程式會先執行 resume() 後面的程式碼,也就是:
print("Done")
等作業系統通知傳回 Response 之後,才回過頭執行匿名函式,這樣的運作方式稱為「非同步」(asynchronous)。
但問題是 print("Done") 之後已經到程式末尾,整個程式結束了,沒機會等到回應(Response)了,所以匿名函式裡面的3個 print() 根本就沒有機會執行。
怎麼辦?下一節會提出解決辦法。
本節的範例程式雖然不成功,但是帶出的觀念卻非常重要,尤其是非同步(asynchronous),是整個第3單元的核心觀念!所以請務必充分理解本節內容,包括以下註解。
【註解】
1. Session 是從頭到尾完成一項任務的意思,任務中間可能分成若干工作或步驟,任務或工作不拘大小,可簡單可複雜。生活上,立法院的一個會期,或是學校的一學期課程,也可稱為一個 Session。
2. Task 是較短的工作或差事,一個 Session 通常可分解為一到多個 Task。
3. 因此URLSession 執行過程要先產出一個 URLSession() 任務實例,再產出一個或多個 dataTask()/downloadTask()/uploadTask() 工作實例。
4. URLSession.shared 是一個作業系統預先產出的任務實例,在任何App或程式任何地方使用 URLSession.shared,都指向同一個任務實例,所以稱為 shared (共享)。
5. resume 字面上是(暫停後)繼續、恢復、重新開始的意思,注意s發/z/音。當作名詞時(法文發音),是履歷、簡歷之意。
6. synchronous 字面意思是在同一時間出現或以相同速度前進,中文稱「同步的」,而asynchronous則是相反,稱為「非同步」,特別指時間上無法協調或預期的事件。舉例來說,兩個人用手機通話,或是看直播影片,對人來說,這是「同步」的,因為必須在同一時間發生;而用 LINE 或 e-mail 溝通則是非同步的,因為對方什麼時候會讀取或回覆是無法預期的。
7. 非同步事件(asynchronous event)是很重要的概念,可理解為「在某個無法預期的時間點會發生的事情」,對Client App來說,網路回應(Response)就是個非同步事件,而對Server來說,網路請求(Request)才是非同步事件。URLSession 正確用法
在前一節我們第一次使用 URLSession 進行網路連線,雖然語法正確,但是卻沒收到回傳資料,因為 URLSession網路連線是「非同步」的運作模式,發出請求(Request)之後,並不會在原地等待,而是往下繼續執行,太早結束來不及收到回傳資料。
所以如果想要等到回傳資料,直覺上的解決辦法,就是在 resume()後面設法等待。在第1單元1-10課曾經學過用 Date() 計算時間差,正好適合用來打發時間。
3-1c URLSession (Good)
以下範例程式加了一個迴圈等待,最後成功收到回傳資料!
- 在「Main」模組中撰寫程式:
// 3-1c URLSession (Good) // Created by Philip, Heman, Jean 2021/09/04 // Revised by Jean 2024/11/02 import Foundation let 網址 = "https://joc.url.tw/Swift36/2021TaiwanBirds.json" var 回傳資料: Data? let 計時開始 = Date() var 時間差 = Date().timeIntervalSince(計時開始) if let myURL = URL(string: 網址) { print("送出訊息...") URLSession.shared.dataTask(with: myURL) { data, response, error in print("回傳資料:", data ?? "No data") print("回應代碼:", response ?? "No response") print("錯誤代碼:", error ?? "No error") 回傳資料 = data }.resume() } while 回傳資料 == nil && 時間差 < 5.0 { 時間差 = Date().timeIntervalSince(計時開始) } print("花費時間(秒):", 時間差)
程式執行結果,如下圖。
網路連線前後花費約0.167秒。
這次我們在 resume() 後面加上一段while迴圈,因此在 URLSession.shared.dataTask().resume()發出請求之後,就會進入這個迴圈:
while 回傳資料 == nil && 時間差 < 5.0 { 時間差 = Date().timeIntervalSince(計時開始) }
若是還未收到「回傳資料」而且「時間差」小於5.0秒,則一直反覆計算時間差,用來打發時間。5秒鐘是我們設定的逾時(timeout)門檻,如果超過5秒還未收到資料就脫離迴圈放棄等待,正常情況下,1秒鐘左右就會收到資料。
在執行迴圈的某一時刻,作業系統會通知程式已收到回傳資料,這時候會立刻「暫停」迴圈,回頭執行 URLSession.shared.dataTask()的匿名函式,匿名函式結束後再從迴圈暫停的地方繼續執行,但這時迴圈的條件 「回傳資料 == nil」已不成立,因此便會脫離迴圈。
這樣的方法有點笨,因為要一直去反覆確認「回傳資料 == nil」,相當於詢問「回傳資料收到沒」?還好電腦不會煩,只是比較浪費CPU資源。
這次我們在使用 URL 物件時,不像之前用驚嘆號 ! 強制取用:
let myURL = URL(string: 網址)!
而是加上一個 if 條件句,如果 URL() 物件初始化成功,才進入 { } 內繼續執行,這樣的寫法比較安全,即使網址有中文字或特殊符號,無法轉換成 URL 物件,也不會讓程式發生錯誤而閃退。
if let myURL = URL(string: 網址) { .... }
要注意 if 條件句的 { } 段落,若是在裡面定義新的變數或常數,其有效範圍(scope)是侷限在 { } 內的,不能拿到 { } 外面使用,與 for-in 迴圈變數的有效範圍類似。
同樣,在 URLSession.shared.dataTask() 匿名函式的3個參數 data, response, error,也無法帶出匿名函式之外,但是在後面我們又需要判斷資料收到沒,所以我們用一個全域變數「回傳資料」,將 data 指定給「回傳資料」,這樣就可間接將 data 帶出來。
URLSession.shared.dataTask(with: myURL) { data, response, error in print("回傳資料:", data ?? "No data") print("回應代碼:", response ?? "No response") print("錯誤代碼:", error ?? "No error") 回傳資料 = data }.resume()
另外,注意這次 print() 內的用法,對於 data, response, error 這三個都是 Optional 資料類型的變數,最便捷的使用方式就是用 ?? 二選一,像這樣:
data ?? "No data"
語法上這相當於「如果 data 是 nil,就用 "No data" 字串,否則就取用 data 內容」:
data == nil ? "No data" : data!
兩相比較,使用 ?? 不需加驚嘆號強制取用(強制取用如果失敗會造成閃退),比較安全又方便。
最後執行結果顯示在主控台的內容如下:
送出訊息... 回傳資料: 73631 bytes 回應代碼: <nshttpurlresponse: 0x7fd17b62e0b0=""> { URL: https://joc.url.tw/Swift36/2021TaiwanBirds.json } { Status Code: 200, Headers { "Alt-Svc" = ( "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"" ); "Cache-Control" = ( "no-cache, no-store, max-age=0, must-revalidate" ); "Content-Encoding" = ( gzip ); "Content-Type" = ( "text/html; charset=utf-8" ); ....<省略> } } 錯誤代碼: No error 花費時間(秒): 0.1671760082244873
「回傳資料」的資料類型是 Data?(Data 加問號 ? 變成 Optional類型),Data 是沒有結構化的原始資料,所以 print() 出來時,「回傳資料」只能顯示 "7766 bytes",而不會告訴你是什麼內容。
回應代碼(response)則是結構化的資料類型,所以有一連串的 key-value 組合(參考第2單元2-8課末key path註解),各有其意義,在此我們不必理會,只要確認有內容即可。
最後的錯誤代碼(error)回傳 nil,表示連線正常。
【註解】
Data是一個通用資料類型,我們在第12課傑森解碼器曾經用過,從本地檔案讀取或網路傳回的資料,不管是文字、文件、圖片、JSON格式....等,在解碼或分解結構之前,都可一律塞在 Data 裡面,其實就是一連串的0與1,所以又稱為原始資料(raw data),英文 raw 是生的,沒有處理過的意思。