[教學] Google 表單 製作多選複選選項 與限制總量數量(團購表單好用)

因為 Choice Eliminator 2 這個第三方外掛已經不能用了(第三方外掛的廠商已經下線了)
這陣子就在找替代方案,發現這一招還是可用,分享給大家。

本教學會帶入部分 Google 表單與 Google 試算表的使用,
看不懂也不用擔心,會一步一步帶大家建立。

教學步驟


(這是範例成品)

我們在這舉一個例子,假設是一個蛋糕團購表單,有六個選項,
在六個選項中,選擇二個:

  • 原味
  • 抹茶
  • 起司
  • 草莓
  • 芋頭
  • 巧克力

在這團購表單很常見。

Step1. 建立表單

首先先建立一個 Google 表單,

網址:
https://docs.google.com/forms/

按新增

這邊用打入標題、 等聯絡資訊。
用簡答功能打入 姓名、電話、地址 等聯絡資訊。
旁邊有加號可以新增題目,這功能就不贅述。

再來就是重頭戲,新增一個「核取方塊」,
打入 原味、抹茶、起司、草莓、芋頭、巧克力 六個選項

在右下角的點點點選項,選擇「回應驗證」

就會多出一行選項

選擇「選取剛好」,數量打入 2,最後輸入提示訊息

再來點到「回覆」頁籤,右上角一個小按鈕「建立試算表」

選取回應目的地的視窗中
選建立新試算表,輸入名字,然後按「確定」

動畫就像這樣

你就會看到打開了一個 Google 試算表。

Step2. 製作回應表單的試算表

在試算表下方按下「 + 」,建立試算表

我們建立二個新的試算表,名字分別叫做:

  • 選項拆解
  • 統計

像這樣

Step3.「選項拆解」的試算表設計

在「選項拆解」的試算表中,

A1 打入「選項」
B2 ~ G2 依序打入「原味、抹茶、起司、草莓、芋頭、巧克力」等選項

接下來這個騷操作,要一步完成
先選擇 A2 這格,先打一個「 = 」(等號)
然後直接選擇「表單回應 1」,的第一格選項欄位

你會看到公式跳出來

='表單回應 1'!E2

動畫在這:

未來填單有資料的時候就會這樣

意思是說,單引號括起來的是試算表的名字,
意指「表單回應 1」試算表裡的 E2 欄位。

如果你是直接複製公式的話,
要注意單引號與雙引號,還有要注意欄位格子是否正確。

按下 Enter 之後,他會顯示空白的是正常現象,選取上去就會該格子就會有公式出現。

Step3-1.「選項拆解」試算表套用搜尋公式

選到 B2 這格,打上公式:

=IF(IFERROR(FIND(B$1, $A2), 0) > 0, 1, 0)

然後選上 B2 這格,直接拖拉到 G2,讓這列的選項都填滿了公式。

動畫:


如果有興趣的朋友,這邊小小解釋一下這個公式的原理:

FIND() 輸入三個參數:

  • 要找的文字
  • 原始文字
  • 從第幾個字開始找(可忽略不填)

白話文就是在第二個參數中,找第一個參數的文字,在哪個位置,
從第三個參數開始找,第三個參數可忽略,就是從第一個字開始找。

輸出就是在第幾個字出現的位置。

FIND() 的說明文件:
https://support.google.com/docs/answer/3094126?hl=zh-Hant

IFERROR() 輸入二個參數:

  • 要執行的公式
  • 出錯時要顯示的字

第一個參數是執行公式,如果它出錯了,就顯示第二個參數值。

IFERROR() 的說明文件:
https://support.google.com/docs/answer/3093304?hl=zh-Hant

IF() 就是判斷,輸入三個參數:

  • 判斷的公式
  • true 時顯示文字
  • false 時顯示文字

算蠻好理解的。

IF() 的說明文件:
https://support.google.com/docs/answer/3093364?hl=zh-Hant

整段白話文就是:我們把選項拆解,有搜尋到字的欄位標成 1 否則是 0


選取 要估算一下會填單的人數,整列選起來往下拉,讓每列都填上公式。
至於要拉幾列,當然 越多越好

這表格意旨在拆解選項並填到各個對應的格子裡。

Step4.「統計」的試算表設計

切到「統計」的試算表

A1 ~ E1 欄位分別打上「顯示、選項、額滿提示、已達數量、數量上限」
然後 B2 ~ B7 依序分別打上總共有的「原味、抹茶、起司、草莓、芋頭、巧克力」選項,
額滿提示可以打上你想打的額滿提示,範例是在最後加上(完售)字樣。

如果你想隱藏該選項,在額滿提示直接留空白。

然後在 E2 ~ E7 打入你要的數量上限。

Step4-1.「統計」試算表套加總公式

選擇 D2 套入加總公式

=SUM('選項拆解'!B:B)

這句 SUM() 的意思是:做 「選項拆解」試算表裡的 B 欄所有列的加總,

然後如法炮製,依序在 D3 套入公式

=SUM('選項拆解'!C:C)

在 D4 套入公式

=SUM('選項拆解'!D:D)

以此類推,套到 D7

=SUM('選項拆解'!G:G)

這邊重點要注意統計欄位是不是正確

Step4-2.「統計」試算表套顯示判斷公式

選擇 A2 這格,套入公式

=IF(D2<E2,B2,C2)

這個應該很好理解,就是數量統計超過上限時,顯示額滿提示,否則顯示原本的選項名

然後往下拉,填滿到 A7

為何要這樣設計呢?為了配合等下要提到的 Form Ranger 外掛

Step5. 安裝 Form Ranger 表單外掛

在右上方點點點點開「外掛程式」,搜尋「Form Ranger」

也就是這個外掛:
https://workspace.google.com/u/5/marketplace/app/form_ranger/387838027286?hl=zh&pann=forms_addon_widget

安裝,

選擇帳號,

瀏覽授權權限,按下「允許」,

允許這個第三方外掛。

Step6. 使用 Form Ranger 表單外掛

然後回到剛剛做的表單,在右上角多一個小積木
選擇「formRanger — PROD」

點「Start」

會看到你的多選問題

勾選「Populate from range」,然後旁邊按「 + 」(New Range)

在 Select sheet 的頁籤中,

選擇剛剛的回應試算表「蛋糕訂購表單 (回覆)」,按「Select」。

在 Select Ranage 的頁籤中,

Sheet name 選「統計」
Column header 選「顯示」

左側會出現預覽,按下「Next」

最後在 Name range 頁籤中,

Range name 打入一個名稱(名稱辨識用,可隨意填)
然後按下「Save and populate question」

最後的最後,

在 Auto-repopulate questions (自動重新產生問題) 這邊,
記得要把 On form submit (每次表單送出時) 選項給勾起來,
它會在表單送出的時候根據欄位值重新產生表單。

如果資料不同步時,可以手動按下「Update questions list」手動更新表單。

Step7. 發佈連結

表單右上角按下「傳送」,

看到傳送表單的視窗,

選擇「連結」圖示,可以勾「縮短網址」,然後按複製,
這個就是你的表單前台網址。

Step8. 測試!

最後當然是當個客人,來測試一下運作情況啦!

拿剛剛產生的這個網址去瀏覽器真的填看看,也驗證一下會不會有問題,
還是哪裡有做錯的地方。

最後附上筆者隨意填三筆資料的觀察各表單結果。

表單回應

選項拆解

統計

前台表單的樣子

補充

最後的最後,如果你想隱藏額滿的選項,
在額滿提示直接留空白就可以了,選項他會自動消失。

記得時不時注意一下,「選項拆解」試算表套公式的列數,不然會出錯。
這功能不管單選多選都可以適用,選項在對應的地方修改即可,變通一下就有超萬用的表單了。

希望這邊教學對你有幫助。

參考資料

[Linux 架站] Wireguard VPN 設定教學

Wireguard 是由 Jason A. Donenfeld 開發的,
基於 UDP 協定的 VPN (Virtual private network) 程式,
有著比 IPsec 與 OpenVPN 更高的效能,
但設定上有一些些小複雜,這篇教學就來釋疑關於 Wireguard 詳細伺服器與用戶端的設定。

安裝 (以下是 Ubuntu 的指令)

sudo apt install wireguard

這裡有各式系統版本安裝指令:https://www.wireguard.com/install/

建立伺服器 (Server) 金鑰

建立伺服器 (Server) 金鑰,
金鑰是非對稱式加密的金鑰,有公鑰與私鑰各一對,
產生金鑰很簡單,用一行指令完成:

$ wg genkey | tee server_privateKey | wg pubkey > server_publicKey

就會產生二個檔案:

  • server_privateKey 伺服器私鑰
  • server_publicKey 伺服器公鑰

先留著備用。

建立用戶端 (Client) 的金鑰

建立用戶端 (Client) 的金鑰,指令相同:

$ wg genkey | tee client1_privateKey | wg pubkey > client1_publicKey

就會產生二個檔案:

  • client1_privateKey 用戶01私鑰
  • client1_publicKey 用戶01公鑰

留著備用。

建立共享密鑰 (PresharedKey) (非必要)

這選項非必要,如果你需要加強防護,可以再建立這個 共享密鑰 (PresharedKey) 來加強防護。
這個金鑰只需要產生 一個 就好。伺服器、用戶端 通通都是設定這一把。
如果要加共享密鑰,就通通都加,如果不加就通通不加。

wg genpsk > psk

留著備用。

設定值

接下來就是讓人有點混亂的地方了,撰寫設定檔。
以下分成三個部分,伺服器設定檔、 Client01 設定檔、 Client02 設定檔 來分別介紹。

伺服器 (Server) 設定檔

我們可以用 vi 來修改設定檔

$ vi /etc/wireguard/wg0.conf

有可能沒有這個資料夾,有可能沒有這個檔案,請自行建立。

大致會填入:

  1. 伺服器私鑰 Server PrivateKey
  2. 伺服器配發 IP 地址(假設為:192.168.100.1/24)
  3. 連接埠 (Port) (假設為:51820)
  4. 所有用戶的設定
    1. 該用戶配發的 IP 位址 (需事先指定好)
    2. 該用戶的公鑰 (PublicKey)

大致範例如下:

[Interface]
Address = 192.168.100.1/24
PrivateKey = IDnOe........(伺服器私鑰 Server PrivateKey)........xNFM=
MTU = 1500
ListenPort = 51820
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT && iptables -A FORWARD -o wg0 -j ACCEPT && iptables -t nat -A POSTROUTING -o ens4 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT && iptables -D FORWARD -o wg0 -j ACCEPT && iptables -t nat -D POSTROUTING -o ens4 -j MASQUERADE

# Client01
[Peer]
AllowedIPs = 192.168.100.2/32
PublicKey = p58AA........(用戶01公鑰 Client01 PublicKey)........cK8AY=

# Client02
[Peer]
AllowedIPs = 192.168.100.3/32
PublicKey = JM5oVD........(用戶02公鑰 Client02 PublicKey)........Axjyc=

分成 [Interface] 段落與 [Peer] 段落

伺服器 (Server) 的 [Interface] 段落

伺服器的 [Interface] 段落,存放伺服器的設定

  • Address:為 VPN 內網的 IP 位址
    (範例是 IP 是 192.168.100.1,子網路遮罩 24 意思為 255.255.255.0)
  • PrivateKey:這邊填入剛剛產生的伺服器私鑰
  • MTU:為 Maximum Transmission Unit (最大傳輸單元)
    意指設定最大封包大小(這個值可不設定)
  • ListenPort:聆聽的連接埠 (Port)
  • PostUp:當 VPN 服務啟動時要連帶執行的指令
  • PostDown:恰巧跟前者相反,當 VPN 服務停止時要連帶執行的指令

伺服器 (Server) 的 [Peer] 段落

伺服器的 [Peer] 段落,是存放 能連進來的用戶 列表
有幾個用戶就設定幾段 [Peer] 段落
上面例子是設定二個能連進來的用戶
(Server 的 Peer 定義跟 Client 定義不一樣,要注意)

  • PublicKey:這邊填入用戶的公鑰
  • PresharedKey:填入產生共享密鑰之 psk 檔內容(該選項非必要)
  • AllowedIPs:設定該用戶連進來配發的 IP 位址,每個人都必須唯一值,不可重複

用戶一號 Client01 設定檔

以下列出 Client 設定檔,可以參酌修改。
需要填入:

  1. 用戶01私鑰 Client01 PrivateKey
  2. 伺服器公鑰 Server PublicKey
  3. 配發用戶01的 IP (假設為:192.168.100.2/24)
  4. 伺服器位址 (假設為:vpn.example.com:51820)
  5. 共享密鑰 (如果有的話,本範例沒有)
[Interface]
PrivateKey = AOAHHE........(用戶01私鑰 Client01 PrivateKey)........7RDE0=
Address = 192.168.100.2/24
DNS = 1.1.1.1

[Peer]
PublicKey = RNrgG........(伺服器公鑰 Server PublicKey)........EpJ0A=
AllowedIPs = 192.168.1.0/24
Endpoint = vpn.example.com:51820
PersistentKeepalive = 30

用戶二號 Client02 設定檔

跟 Client01 差不多,只是有小小的不同,故列出來給大家參考。
需要填入:

  1. 用戶02私鑰 Client02 PrivateKey
  2. 伺服器公鑰 Server PublicKey
  3. 配發用戶02的 IP (假設為:192.168.100.3/24)
  4. 伺服器位址 (假設為:vpn.example.com:51820)
  5. 共享密鑰 (如果有的話,本範例沒有)
[Interface]
PrivateKey = eWEo5G........(用戶02私鑰 Client02 PrivateKey)........0RBQc=
Address = 192.168.100.3/24
DNS = 1.1.1.1

[Peer]
PublicKey = RNrgG........(伺服器公鑰 Server PublicKey)........EpJ0A=
AllowedIPs = 192.168.1.0/24
Endpoint = vpn.example.com:51820
PersistentKeepalive = 30

二個用戶基本上大同小異,一併這邊做一個說明

用戶端 (Client) 的 [Interface] 段落

  • PrivateKey:這邊填入該用戶的私鑰
  • Address:填入與伺服器設定相同配發 IP 位址,一定要跟伺服器寫的一致
  • DNS:給定指定的 DNS Server (範例為:1.1.1.1 也可以用 8.8.8.8 或者別的 DNS)

用戶端 (Client) 的 [Peer] 段落

這邊定義了伺服器的連線資訊
(Server 的 Peer 定義跟 Client 定義不一樣,要注意)

  • PublicKey:填入伺服器的公鑰
  • PresharedKey:填入產生共享密鑰之 psk 檔內容(該選項非必要)
  • AllowedIPs:這邊設定比較特別,設定 哪些網段的流量會流向 VPN
    如果設定 0.0.0.0/0 的話,意指所有對外連線都會流向 VPN(如果要透過跳板機來上網就可以如此設定)。
    你可以設定特定的網段,例如伺服器群所在的網段,意指如果有連接伺服器群才會流向 VPN,否則直接對外,直接直連 Internet 而不透過 VPN。
  • Endpoint:連線到伺服器的位址(可以用網址,也可以用 IP)
  • PersistentKeepalive:保持連線的設定,範例設定 30 意指 30 秒會檢查一次連線。

啟動 Wireguard 伺服器

$ wg-quick up wg0

這樣可以把這個 VPN 伺服器啟動,你會發現多一個網路介面,名字就是我們命名的 wg0

可以用以下指令列出網路介面

$ ip a

啟動 Wireguard 用戶端 (Client) ,連線至伺服器

$ wg-quick up client1

然後測試

$ curl ipinfo.io/ip

看看 IP 有沒有變化?(如果全部對外流量都流進 VPN 的話,理論上 Public IP 位址一定會不一樣。)
或者 ping 看看你的伺服器群

(在 ping 沒有關的時候),測試看看有沒有正常回應 ping

Troubleshooting 疑難排解

設定 VPN 最難的就是除錯了。列出一些我遇到的問題。最多的情況就是連不上,
但連不上分成很多種情況,小弟這邊列出來可以一一檢查。

可以觀察

  • Data received
  • Data sent
  • Lastest handshake

這幾個值還有 Log

問題:做不了 Handshake

如果 Data received 少的很可憐,Log 顯示沒有做到一次 Handshake,很有可能是被擋了。
請檢查防火牆 (Firewall) 有沒有開對應的 Port。
尤其是雲端主機(Amazon, GCP…等),他們後台有自帶一個防火牆,直接從他的後台做設定。

用戶端請檢查連線資訊是否有錯?欲連接的 IP 位址、連接埠 (Port) 是否有錯?
測試一下 DNS,是不是存粹 DNS 無法正確解析?
而或者你的 Nameserver 的 A 紀錄根本就是設錯的?

pingnslookup 查證。

問題:Handshake 有成功,但網路整個連不上

要檢查 Wireguard 伺服器 (Server) 設定的 PostUpPostDown 裡面的指令有沒有問題。
裡面有寫了二個介面 wg0ens4

  • wg0:是 Wireguard 啟動時,會自動產生的介面名稱,這個 需跟你的檔名一致
  • ens4:是對外的介面名稱 (Interface),也有可能是 eth0 或者別的名字。

用戶端 [Peer] 段落的 AllowedIPs 是不是設定了 0.0.0.0/0
意指所有對外連線都會流向 VPN,如果設定上去整個網路貌似斷線就是這個緣故。

伺服器端 (Server) 的 net.ipv4.ip_forwardnet.ipv6.conf.all.forwarding 有沒有正確打開?
因為 Wireguard 伺服器設定的 PostUpPostDown 裡面有設定 FORWARD 必須打開。

問題:Handshake 有成功,但 ping 不到伺服器

如果是設定 VPN 來存取伺服器群的話,
可以測試連上 VPN 後,使用伺服器的 私人 IP (Private IP) 能不能正確 ping 到?
如果不行,請檢查用戶端 [Peer] 段落的 AllowedIPs 網段是不是設錯?
尤其是 子網路遮罩 (Subnet mask) 是不是有算對?

如果是 Class B 的 私人 IP
就不會是常見的 /24 (255.255.255.0)
而會是 /20 (255.255.240.0)

看你伺服器群的網路怎麼設計。

重點整理

最後統合幾個設定重點:

  • 整套系統採非對稱式加密,會產生公鑰私鑰一對金鑰,
    用公鑰加密、私鑰可解密
    故需要二對金鑰
  • 所有的用戶端 (Client) 需要預先指定好分配的 IP 位址
  • 伺服器 (Server):填入伺服器自身的私鑰,與所有用戶端 (Client) 公鑰
  • 用戶端 (Client):填入伺服器公鑰與自身的私鑰
  • 記得查看 Log 來除錯 (debug) 並檢查設定檔問題

以上幾點供參考,祝設定順利。😊

很複雜?來,這有工具XD

很後來亂逛才看到的,直接幫你生好
https://www.wireguardconfig.com/

參考資料

[Android & PHP] 伺服器端 IAP(IAB) 內購通知實作 Real-time Developer Notifications (RTDN)

因為工作需要,研究了這塊,
因為文件有點不連續,又蠻深入的,
左思右想,還是分享出來,給有需要串接 App 內購的朋友,
App 內購在 Android 叫做 In-app Billing (IAB) 在蘋果 iOS 這邊叫做 In-app purchase (IAP),
講得就是一個東西,App 需要購買虛擬商品的時候,必須透過 GooglePlay (Android 平台) 或者蘋果 Apple (iOS 平台) 的金流。

為什麼會需要它?

這邊專注講 Android 平台的 In-app Billing (IAB),

要提到一下 App 內購的流程:

  1. 由使用者按下購買鈕,你的 Android 程式呼叫系統購買頁面
  2. 使用者在系統購買頁面做扣款事宜
    (如果有設定好的話,可能是指紋或者刷臉再次確認,如果第一次使用就會要求你做登入 Google 帳號,輸入信用卡等等的步驟再跳回來)
  3. 你的 Android 程式收到購買狀態(成功/失敗/使用者取消) 並回傳 purchaseToken
  4. 如果購買成功,你的 Android 收到 purchaseToken 之後要 回傳給你的後端伺服器做驗證啟用對應功能給使用者

最後這一步才是關鍵,後端伺服器確認了之後才會開啟對應功能給使用者嘛。
(App 無伺服器的流程是另外一種,而且漏洞很多,先姑且不討論。)

正常流程大致是這樣,但總有一些些例外。

例如:

  • (訂閱型商品常見)每月時間到了系統自動做扣款動作
  • 使用者是在 GooglePlay 商店按的購買而不是在你 App 裡面按
  • Google 那邊刷使用者的卡刷失敗了

等等諸如此類,它並不是發生在你的 App 中,所以你的 App 沒辦法知道那時候訂的商品狀態更新了。

怎麼辦呢?

以前的傳統做法:在 你的 App 啟動時 或者 喚醒 的時候,向 Google 再次確認一次有沒有未處理的購買訂單。

如果訂單不同步怎麼辦?請使用者再次開一次 App 吧!訂單就同步了。

如果你只有一個 Android 平台,
這沒問題,使用者的所有流程都是在你的 App 下進行的,
但如今你的服務可能會有 iOS 平台、 Android 平台、網站…等等的平台。
如果使用者買了之後沒有開 App ,很容易會造成訂單不一致問題。
這個時候就需要內購伺服器通知了。

總結來說,這個 App 內購伺服器通知就是為了解決訂單不同步的問題,
透過伺服器與伺服器對傳通知確認,即時補齊 App 外交易的訂單。

這個 App 內購伺服器通知叫做 Real-time Developer Notifications (RTDN)。

怎麼做?

在使用這個之前,必須先知道 Google 出的 Pub/Sub 模組。
他會透過 Pub/Sub 模組發通知出來,我們可以用 Push (常用) 或者 Pull 方式實作。

Pub/Sub 模組

Pub/Sub 模組它是一個類似廣播模組,如果有需要的話,就設定註冊它,你就會收到來自程式的廣播通知。
一個頻道稱為 Topic
一個訂閱人叫做 Subscription

其中 Subscription 分為 Pull 跟 Push 二種。

  • Pull: 為一個 Queue (佇列) ,幫你 Queue 住所有的訊息直到你打 API 去拉它,
  • Push: 要指定一個 API endpoint 給它,網址就是填你的伺服器 API 位址,他會用文件定義的 API 格式,主動去呼叫你的伺服器。
    (這部分就跟 Apple 概念接近了,這也是最常用的方式。)

官方文件說,一個 app 要獨立一個 Google Cloud project 這點要注意。
在有限額度下,多開 Google Cloud project 帳號應該也無仿。

操作步驟

等等的步驟大致是,
建立一個 Topic 與其對應的 Subscription,然後 Subscription 設定好連接你的伺服器,
設定 app 變更通知要發送到哪一個 Topic。
設定有點小複雜,可能要慢慢研究一下。

到 Google Cloud Console 新增一個 project
(如果有的話就選擇你的 project,假設叫做 my_app_project

Step1. 建立 Topic

到 BigData > Pub/Sub 的頁籤中:
https://console.cloud.google.com/cloudpubsub/topic/list

先建立一個 Topic,
名字自訂,假設叫 my_app_topic

有勾 Add a default subscription 的話,它會自動幫你建立一個 Pull 的 Subscription 叫做 my_app_topic-sub
我們等等會使用 Push 的 Subscription,這個 my_app_topic-sub 的 Pull Subscription 可以先留著,
到時候可以直接在 Cloud Console 這邊直接驗證。

Step2. 測試發送訊息到 Topic

可以先試試看 Topic 發通知跟接收通知的感覺,
在 Pub/Sub > Topic 頁籤中,

點開剛剛建立的 my_app_topic
在 MESSAGES 頁籤,有一個 PUBLISH MESSAGE 按鈕可以按,

按下後看到發送測試訊息的畫面,可以在 Message body 的地方隨意打點字上去,
然後按 publish 發送。

在 Pub/Sub > Subscription 頁籤中,點開自動建立的 my_app_topic-sub

點開 MESSAGES 頁籤,看到列表是空白是正常的,

按下 PULL 更新通知,剛剛發送的訊息就會出現,如果沒有出現,可能要稍等個 1-2分鐘 再按一次(因為它發送跟接收是非同步的)。

Step3. 通知連接你的 app

這邊就是它文件寫的最奇妙的地方了,

在 Pub/Sub > Topic 的頁籤列表中,按下三顆點點 > View premissions 按鈕,

按下 ADD PRINCIPAL 按鈕,

在 New principals 的地方手動輸入 [email protected]
Select a role 的地方手動選擇 Pub/Sub Publisher ,
(這個值是 Google 指定的 Service account 名字,寫在文件裡的,大家都一樣)

然後進入 Google play console,
https://play.google.com/console
並選擇你的 app。

在 Monetization setup 的頁面中,
Real-time developer notifications 區域,
Topic name 的地方, 按照格式 打入剛剛的 Topic 名字:

projects/my_app_project/topics/my_app_topic

(這邊要依照你實際情況來打,每個人不太一樣)

按下 Save changes 然後按下 Send test notification 試試。

Step4. 測試通知發送連接情況

在剛剛 Real-time developer notifications 區域,
按下 Send test notification 試試,
流程跟單獨測試 Topic 類似,
回到剛剛 Pub/Sub > Topic 頁籤中,
在 Pub/Sub > Subscription 頁籤中,點開剛剛的 my_app_topic-sub
按下 PULL 更新通知,會出現測試類似以下的訊息,如果沒有出現,可能要稍等個 1-2分鐘 再按一次(因為它發送跟接收是非同步的)

{"version":"1.0","packageName":"com.myawesome.app","eventTimeMillis":"1638346194077","testNotification":{"version":"1.0"}}

實作伺服器接收端 (PHP)

接收端分成二個方式 Pull 跟 Push 的方式,二個擇一實作即可。
個人比較推薦使用 Push 的方式。

Pull 的方式

以下是 PHP 實作,
如果你不是是用這語言開發,可以參考他其他的 Client library 來實作
https://cloud.google.com/pubsub/docs/reference/libraries#cloud-console

PHP 做法就是使用 composer 引用 cloud-pubsubgoogle/apiclient 套件。

$ composer require google/apiclient
$ composer require google/cloud-pubsub

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
    "google/cloud-pubsub": "^1.34"
  }
}

再執行

$ composer update

你可能需要建立一個 Service account 並給予 Pub/Sub Subscriber 權限
在 Key 的地方按下 Add Key 選擇 json 得到 credentials.json

credentials.json 文件大概長這樣:

{
  "type": "service_account",
  "project_id": "my_app_project",
  "private_key_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBA.......N4M8zLc\n-----END PRIVATE KEY-----\n",
  "client_email": "my-service-account@my_app_project.iam.gserviceaccount.com",
  "client_id": "xxxxxxxxxxxxxxxxxxxxx",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my_app_project.iam.gserviceaccount.com"
}

credentials.json 放到伺服器能存取到的地方,假設是放在跟 PHP 相同的目錄
就可以新增一個 PubSubClient 物件來抓取 Pull 的通知

<?php
require __DIR__ . '/vendor/autoload.php';

use Google\Cloud\PubSub\PubSubClient;

$pubSub = new PubSubClient([
    'keyFilePath' => __DIR__ . '/credentials.json',
    'projectId' => 'my_app_project'
]);

$subscription = $pubSub->subscription('my_app_topic-sub');

$messages = $subscription->pull();

foreach ($messages as $message) {
    echo $message->data();
}

這個範例就能跟網頁介面一樣,一次拉一些一次拉一些,拉到你想要的訊息,
可能用一個 cronjob 排程讓它固定跑,就可以使用它。

你就可以得到類似這樣的一筆資料:

{
  "version": "1.0",
  "packageName": "com.myawesome.app",
  "eventTimeMillis": "1638375316338",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 13,
    "purchaseToken": "df................CnPIQ",
    "subscriptionId": "com.myawesome.app.one_month_subscription"
  }
}

這個是一個訂閱通知的範例,
根據查表 notificationType = 13 是 SUBSCRIPTION_EXPIRED 也就是訂閱過期了。

根據文件,除了 subscriptionNotification(訂閱通知)之外,它還有 oneTimeProductNotification(單次購賣通知)
細節就不多講了,格式很接近

{
  "version":"1.0",
  "packageName":"com.myawesome.app",
  "eventTimeMillis":"1638375316338",
  "oneTimeProductNotification":
  {
    "version":"1.0",
    "notificationType":1,
    "purchaseToken":"fg................HBbID",
    "sku":"com.myawesome.app.coin"
  }
}

單次購買的 notificationType 與訂閱的 notificationType 定義不同,需注意。

Push 的方式(常用)

你要設計一個 API endpoint 給他,每當訂單狀態有變更時,Google 伺服器它會用 POST 把資料回傳給你

這邊分成二部分,有認證跟沒認證,先解釋乾淨版不需認證的方式。
(這邊的認證是指認證是否真的由 Google 伺服器來的通知,而不是別人假冒的)

格式參考如下:
https://developer.android.com/google/play/billing/rtdn-reference

接收的文件路徑:
https://cloud.google.com/pubsub/docs/push

你會收到來自 Google 伺服器經由 POST 打來的資料,類似這樣:

{
  "message": {
    "attributes": {
      "key": "value"
    },
    "data": "eyAidmVx................GlvbiB9",
    "messageId": "136969346945"
  },
  "subscription": "projects/my_app_project/subscriptions/mysubscription"
}

其中這個 data 欄位是一個 base64 編碼後的字串,解碼 data 欄位可以得到類似以下的資料:

{
  "version": "1.0",
  "packageName": "com.myawesome.app",
  "eventTimeMillis": "1638375316338",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 13,
    "purchaseToken": "df................CnPIQ",
    "subscriptionId": "com.myawesome.app.one_month_subscription"
  }
}

這個是一個訂閱通知的範例,
根據查表 notificationType = 13 是 SUBSCRIPTION_EXPIRED 也就是訂閱過期了

根據文件,除了 subscriptionNotification(訂閱通知)之外,它還有 oneTimeProductNotification(單次購賣通知)
細節就不多講了,格式很接近

{
  "version":"1.0",
  "packageName":"com.myawesome.app",
  "eventTimeMillis":"1638375316338",
  "oneTimeProductNotification":
  {
    "version":"1.0",
    "notificationType":1,
    "purchaseToken":"fg................HBbID",
    "sku":"com.myawesome.app.coin"
  }
}

單次購買的 notificationType 與訂閱的 notificationType 定義不同,需注意。

查找訂單細節(訂閱型資料)

伺服器訂購通知只給你很粗糙的內容,實際內容還需要你另外打 API 達成。

PHP 需 composer 引用 apiclient 套件

$ composer require google/apiclient

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
  }
}

再執行

$ composer update

以下程式碼是一個抓取訂閱訂單的範例:
需要填入 $packageName, $subscriptionId$purchaseToken

$packageName = 'com.myawesome.app';
$subscriptionId = 'com.myawesome.app.one_month_subscription';
$purchaseToken = 'df................CnPIQ';

$client = new \Google_Client();
try {
    $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
} catch (\Google\Exception $e) {
    throw $e;
}
$client->addScope('https://www.googleapis.com/auth/androidpublisher');
$service = new \Google_Service_AndroidPublisher($client);
$purchase = $service->purchases_subscriptions->get($packageName, $subscriptionId, $purchaseToken);

var_dump($purchase);

大致的資料格式如下(引用官方文件):

{
  "kind": string,
  "startTimeMillis": string,
  "expiryTimeMillis": string,
  "autoResumeTimeMillis": string,
  "autoRenewing": boolean,
  "priceCurrencyCode": string,
  "priceAmountMicros": string,
  "introductoryPriceInfo": {
    object (IntroductoryPriceInfo)
  },
  "countryCode": string,
  "developerPayload": string,
  "paymentState": integer,
  "cancelReason": integer,
  "userCancellationTimeMillis": string,
  "cancelSurveyResult": {
    object (SubscriptionCancelSurveyResult)
  },
  "orderId": string,
  "linkedPurchaseToken": string,
  "purchaseType": integer,
  "priceChange": {
    object (SubscriptionPriceChange)
  },
  "profileName": string,
  "emailAddress": string,
  "givenName": string,
  "familyName": string,
  "profileId": string,
  "acknowledgementState": integer,
  "externalAccountId": string,
  "promotionType": integer,
  "promotionCode": string,
  "obfuscatedExternalAccountId": string,
  "obfuscatedExternalProfileId": string
}

REST 文件在這:
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions
PHP 的資料格式參考(很難看懂,還是參照 REST 文件吧!):
https://developers.google.com/resources/api-libraries/documentation/androidpublisher/v3/php/latest/class-Google_Service_AndroidPublisher_SubscriptionPurchase.html

基本上對應的資料格式會有對應的 Method 可以呼叫。

查找訂單細節(單次購買型資料)

伺服器訂購通知只給你很粗糙的內容,實際內容還需要你另外打 API 達成。

PHP 需 composer 引用 apiclient 套件

$ composer require google/apiclient

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
  }
}

再執行

$ composer update

以下程式碼是一個抓取訂閱訂單的範例:
需要填入 $packageName, $sku$purchaseToken

$packageName = 'com.myawesome.app';
$sku = 'com.myawesome.app.coin';
$purchaseToken = 'fg................HBbID';

$client = new \Google_Client();
try {
    $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
} catch (\Google\Exception $e) {
    throw $e;
}
$client->addScope('https://www.googleapis.com/auth/androidpublisher');
$service = new \Google_Service_AndroidPublisher($client);
$purchase = $service->purchases_products->get($packageName, $sku, $purchaseToken);

var_dump($purchase);

大致的資料格式如下(引用官方文件):

{
  "kind": string,
  "purchaseTimeMillis": string,
  "purchaseState": integer,
  "consumptionState": integer,
  "developerPayload": string,
  "orderId": string,
  "purchaseType": integer,
  "acknowledgementState": integer,
  "purchaseToken": string,
  "productId": string,
  "quantity": integer,
  "obfuscatedExternalAccountId": string,
  "obfuscatedExternalProfileId": string,
  "regionCode": string
}

REST 文件在這:
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products
PHP 的資料格式參考(很難看懂,還是參照 REST 文件吧!):
https://developers.google.com/resources/api-libraries/documentation/androidpublisher/v3/php/latest/class-Google_Service_AndroidPublisher_ProductPurchase.html

基本上對應的資料格式會有對應的 Method 可以呼叫。

回應 (Acknowledge) 訂單已處理(訂閱型)

有一個很重要的就是,伺服器處理完訂單要打 API 跟 Google 伺服器講:商品已送達。
這個行為稱為 Acknowledge

不然它會認為沒有開對應功能給使用者,計時三天後,自動做退費,所以這功能很重要。

$packageName = 'com.myawesome.app';
$gpaOrderId = 'GPA.xxxx-xxxx-xxxx-xxxxx';
$purchaseToken = 'df................CnPIQ';

try {
  $client = new \Google_Client();
  $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
  $client->addScope('https://www.googleapis.com/auth/androidpublisher'); 
  $service = new \Google_Service_AndroidPublisher($client);

  $postBody = new Google_Service_AndroidPublisher_SubscriptionPurchasesAcknowledgeRequest();
  $postBody->setDeveloperPayload("");
  $response = $service->purchases_subscriptions->acknowledge($packageName, $gpaOrderId, $purchaseToken, $postBody);

  var_dump($response);
} catch (\Google\Exception $e) {
    throw $e;
}

以上是一個簡單範例。

回應 (Acknowledge) 訂單已處理(單次購買型)

伺服器處理完訂單要打 API 跟 Google 伺服器講:商品已送達。
這個行為稱為 Acknowledge

不然它會認為沒有開對應功能給使用者,計時三天後,自動做退費,所以這功能很重要。
原理類似,就不贅述了。

$packageName = 'com.myawesome.app';
$sku = 'com.myawesome.app.coin';
$purchaseToken = 'fg................HBbID';

try {
  $client = new \Google_Client();
  $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
  $client->addScope('https://www.googleapis.com/auth/androidpublisher'); 
  $service = new \Google_Service_AndroidPublisher($client);

  $postBody = new Google_Service_AndroidPublisher_ProductPurchasesAcknowledgeRequest();
  $postBody->setDeveloperPayload("");
  $response = $service->purchases_products->acknowledge($packageName, $sku, $purchaseToken, $postBody);

  var_dump($response);
} catch (\Google\Exception $e) {
    throw $e;
}

以上就是各種實作細節,細節很多,文件很長,可能要多看幾次。
祝串接愉快。

參考資料

[iOS 教學] 在 Apple M1 機器上面正確編譯 iOS 專案

蘋果在 2020 年 11 月發表了新的 Macbook 系列,
與新的 Apple M1 晶片 (Apple silicon),效能與省電上推向另外一個新的層次,
在軟體相容上推出了 Rosetta,利用軟硬體的設計,直接讓 arm 架構的 Apple M1 相容 x86_64 程式。

想當然爾,蘋果推出的 xcode 肯定會支援新的 arm 架構,但原有專案甚至套件管理程式並不完整支援。在 iOS 開發上要怎麼調校,才可以正確編譯,甚至效能上比 Intel 晶片更好呢?
請繼續看下去。

原有專案

原有專案是一個有啟用 CocoaPods 的一個 iOS 專案,其中有若干的套件。
(這應該是一個蠻常見的狀態)
沒調整前沒辦法在 Apple M1 晶片中正確編譯,但在 Intel 晶片上卻可以。

硬體

寫之前先解釋一下筆者的硬體:

  • 型號:MacBook Pro (13-inch, M1, 2020)
  • 晶片:Apple M1
  • macOS 版本:Big Sur 11.5
  • xcode 版本:12.5.1 (12E507)
  • Cocoapod 版本:1.11.2
  • Ruby 版本:2.6.3p62 (2019-04-16 revision 67580) [universal.arm64e-darwin20]

錯誤訊息

錯誤訊息可能會是這個:

Could not find module 'FrameworkName' for target 'arm64-apple-ios-simulator'; found: x86_64-apple-ios-simulator, x86_64

導致其中一個函式庫無法正確跑在模擬器上。

操作步驟

  1. 安裝 Ruby-FFI ,在 x64 模式來安裝
$ sudo arch -x86_64 gem install ffi

Ruby-FFI 大致是一個動態連結原生元件庫的一個元件庫。說明如下:

Ruby-FFI is a gem for programmatically loading dynamically-linked native libraries, binding functions within them, and calling those functions from Ruby code. Moreover, a Ruby-FFI extension works without changes on CRuby (MRI), JRuby, Rubinius and TruffleRuby. Discover why you should write your next extension using Ruby-FFI.

  1. 用 x64 模式來執行 CocoaPods 的各種操作,
    例如最常用的: pod install

原本是:

$ pod install

改成這樣:

$ arch -x86_64 pod install

其他操作以此類推。

  1. [極重要]Pods 的專案中的 Build settings 設定
    Build Active Architecture only 改成 NO

這選項設定成 NO 的意思是,不只編譯當前架構的二進位檔,反而編譯「所有」架構的二進位執行檔。
(中文翻譯大概是這樣)
所有架構,我列出一些已知的,例如:
arm64 arm64e armv7 armv7s i386 x86_64

一次全編譯。

另外一個做法,修改你的 Podfile 檔案,
原理就是用程式的方式,將所有依賴元件,全部修改 Build Active Architecture onlyNO

可以直接把這段寫進 Podfile 中最後面

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |configuration|
      target.build_settings(configuration.name)['ONLY_ACTIVE_ARCH'] = 'NO'
    end
  end
end

這樣 arm 架構的 iOS 模擬器就可以吃到正確的架構執行檔並執行了。

參考資料

[Python] 用 ItChat 套件寫一個微信 WeChat 聊天機器人 ChatBot

微信機器人有很多實作方式,有修改微信電腦版的方式、網頁版登入的方式等等。
我這邊使用的是 Python ,利用登入網頁版微信來做為溝通

註:這個作法是利用「個人」的帳戶方式,實現半自動化的功能。
可以自動拉入群、群發訊息、特定關鍵字回覆…等等的功能

我們使用 ItChat 的套件來實作微信的機器人。

網路上對於 ItChat 有許多分支,最原始的是 littlecodersh/ItChat 的版本,
但小弟在測試的時候發現這個版本有登入的問題(等等會解釋)。
所以改用 luvletter2333/ItChat 修改後的版本

相關文件:
https://itchat.readthedocs.io/zh/latest/

以下使用 Python 3.9 的環境實作。

安裝套件

pip3 install git+https://github.com/luvletter2333/ItChat.git

最基本的一個範例

import itchat

itchat.auto_login()

itchat.send('Hello, filehelper', toUserName='filehelper')

就是向給「文件傳輸助手」發一條「Hello, filehelper」訊息。
因爲不能自己發給自己,但每個人每個帳號都有「文件傳輸助手」故可以用它來測試。

列出好友列表

使用 get_friends() 方法可以獲取所有的好友(好友第一位是自己)

friendList = itchat.get_friends(update=True)[1:]
for friend in friendList:
    print("%s %s" % ((friend['DisplayName'] or friend['NickName']), friend['UserName']))

可以得到 friend 物件,是一個 json 物件。
主要二個重點:

  • UserName 使用者 ID
  • NickName 顯示的暱稱

你發訊息的時候會需要 UserName 代表使用者 ID
資料類似這樣
@@f2148b83xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxa3644c

列出群組聊天

這邊有一個要注意:你的群組聊天,一定要勾選「儲存到通訊錄」
如果不儲存到通訊錄,是無法在各裝置之間同步的(所以 itchat 也無法讀取到)

讀取方式有二種

第一種,類似 get_friends() 的方式,使用 get_chatrooms() 來列出你有加的群組。

chatroomList = itchat.get_chatrooms(update=True)[1:]
for chatroom in chatroomList:
    print("%s %s" % (chatroom['NickName'], chatroom['UserName']))

跟使用者一樣,有 NickName 與 UserName

  • UserName 群組 ID
  • NickName 顯示的暱稱

操作方式與使用者相同

第二種方式,使用 search_chatrooms 方法

chatroomName=u'MyChatGroup'
chatrooms = itchat.search_chatrooms(name=chatroomName)
if chatrooms is None:
    print(u'Chatroom not found: ' + chatroomName)
else:
    print("%s %s" % (chatrooms[0]['NickNameUserName'], chatrooms[0]['UserName']))

發訊息

使用 send 方法

itchat.send(u'Hello, world', userName)

參數就二個,第一個就是訊息,第二個就是 UserName 參數(代表你的群組 ID 或使用者 ID)
資料類似這樣
@@f2148b83xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxa3644c

使用 Docker 包裝

我會使用 Docker 鎖定執行的環境與所需材料,只要一個 docker run
就能帶入當時所使用的環境

FROM python:3.9.7-slim-bullseye

WORKDIR /data

RUN apt-get update
RUN apt-get install -y git
RUN pip3 install requests pyqrcode
RUN pip3 install git+https://github.com/luvletter2333/ItChat.git 

ADD *.py /data/

CMD python3 main.py

使用 python 3.9.7 基於 Debian 11 (代號 bullseye) 的版本
加入所需套件 requests pyqrcode ItChat 等

然後執行之

實例

我寫了一個有趣的微信 Wechat 機器人
抓取財金資訊日曆,整理後貼回群組
提醒大家留意交易的時間點


QRCode 登入畫面(因為 QRCode 是個人登入使用,避免誤掃就隱藏起來)

發送訊息的樣子

對照原有 app

遇到問題

我在測試的時候遇到最多最不知道怎麼解的問題就是這個

「為了你的帳號安全,此微信號不能登錄網頁微信。你可以使用 Windows 微信或 Mac 微信在電腦端登錄」

找了一下相關的討論

Issue#943 這邊提到他用 why2lyj/ItChat-UOS 來實現,但包進 Docker 會沒辦法用。

Issue#939 這邊 luvletter2333/ItChat 的修改就可以用,也就是本文使用的版本

有網友使用老版本的網頁微信,就又可以使用了。
(什麼時候封禁不能用,不知道,看微信的臉色)

有網友回報新註冊的微信就已經不能使用了,
使用前請苛灼。

參考資料

Spring boot 學習筆記 – 使用 Kotlin 做後端開發

學了這麼厲害的 Kotlin 可不可以拿它來開發後端(Backend),答案是當然可以的!
我們使用 Kotlin 跟 Gradle 來做開發
先做一個非常簡單的 API 再來慢慢進階

前端?後端?前台?後台?有差嗎?

在這邊簡單解釋一下 Web 技術這些詞彙的定義,大家也常常搞錯。

  • 前端 (Frontend):由後端 (Backend) 收到資料,透過瀏覽器渲染出來跟使用者互動的介面 (UI) 與程式都稱前端。

  • 後端 (Backend):也就是伺服器 (Server) 的部分,專門處理由使用者的要求,提供對應資料的程式。

  • 前台:大家比較通俗的講法,使用者看得到的操作得到的區域,如同舞台一般。

  • 後台:大家比較通俗的講法,通常是指管理者介面,如同舞台的後台。

那前台、後台跟前端、後端有關係嗎?
答案是:沒有什麼關係。
前台需要有前端(介面),也需要後端(伺服器程式)幫忙,後台也是。

工作上可以細分成:

  • 前台的前端
  • 前台的後端
  • 後台的前端
  • 後台的後端

分別有前端工程師與後端工程師負責開發。
今天的主題是後端開發的主題,所以沒有介面只有資料。

學習目標

  • 手把手製作一個 REST API
  • 熟悉 Spring boot 的 Annotation 寫法
  • 調整 404 頁面

準備開發環境

這邊有三種做法,好壞都不一

  • 直接推薦:InteliJ IDEA Ultimate 付費版
  • Visual studio code 外掛 Spring Initializr plug-ins
  • Spring Tools 4 for Eclipse (前身為 STS3)

💡 小提醒:這個需要付費版 InteliJ IDEA Ultimate, 免費的 Community 會無法正確 Compile,小弟已經親身試過了。

小弟建議使用 InteliJ,因為他的優異程式碼提示、搜尋與重構等功能好過其他的 IDE 編輯器

小提醒:因為 InteliJ IDEA 有可能會小改版,介面可能會稍微長的不太一樣,如果真有找不到選項、或者文章失效歡迎聯繫小弟我。

開新專案

新增SpringBoot專案

New Project,選擇 Spring Initializr

  • Group: <自己填>
  • Artifact: <自己填>
  • Type: Gradle Project (Generate a Gradle based project archive.)
  • Language: Kotlin
  • Packaging: Jar
  • Java Version: 11 (選更新的版本也可以)
  • Version: <自己填>
  • Name: <自己填>
  • Description: <自己填>
  • Package: <自己填>

然後按「Next」。

新增SpringBoot專案

這邊選擇二個:

  • Web > Spring Web
  • Developer Tools > Spring Boot DevTools

不選也沒關係,後面可以自行新增。
按下「Finish」完成建立專案。

如果直接新增完跑的話,Log 大概會長這樣。

瀏覽會長這樣:

資料夾結構

如果都是預設值的話,會看到以下資料夾結構:

  • src/main/kotlin/\<packageName>/
    主要程式碼的目錄
  • src/test/kotlin/\<packageName>/
    測試程式碼的目錄
  • build.gradle.kts
    專案的 gradle 檔案
  • src/main/resources/static
    存放一些靜態資源(例如:JavaScript、css、圖片…等)
  • src/main/resources/templates
    存放一些樣板檔案
  • src/main/resources/application.properties
    參數設定配置檔,存放一些資料連線資訊、雜項設定等等

修改啟動的連接埠 (Port)

找到 application.properties 檔案,加入這句即可

server.port=8086

啟動 Port 就改成 8086 了

其他參數請見:
https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html

增加 Dependencies

打開 build.gradle.kts 找到 dependencies { } 的區塊

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}

即是 Spring Web 跟 Spring Boot DevTools


觀察檔案

DemoApplication.kt (如果沒改名字的話)
或者有 @SpringBootApplication 標明字樣的檔案
即主要程式,程式的進入點
可以找到一個

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

可以找到 main 即程式進入點


寫第一個 Controller 寫一隻 API

著手來寫第一個 Controller

定義資料 Model

我們先定義幾個的 model class,

GeneralMessageResponse 是用來顯示範例 API 資料用的

GeneralMessageResponse.kt

package com.example.demo.model

data class GeneralMessageResponse(val message:String)

就這樣二句,就可以定義好資料 Model。

GeneralErrorResponse 用來顯示錯誤訊息的

GeneralErrorResponse.kt

package com.example.demo.model

data class GeneralErrorResponse(var message:String)

寫一個 Controller

製作一個 class 名叫 HelloController 使用 @RestController 標示,內容如下:

HelloController.kt

package com.example.demo

import com.example.demo.model.GeneralMessageResponse
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class HelloController{

    @RequestMapping("/")
    fun hello(): GeneralMessageResponse {
        return GeneralMessageResponse("Hello, world!")
    }
}

做一個方法 (Method),方法名稱可以自訂,標上 @RequestMapping 標示,回傳一個物件
即回傳的資料

@RequestMapping 上面需標明綁定的 API 路徑(不可重複)
可綁定何種傳送方式 (GET, POST, PUT, DELETE)

可編譯此專案,並瀏覽之
預設會是 http://localhost:8080/

會得到以下結果:

{"message":"Hello, world!"}

新增另外一個 Method

在剛剛的 HelloController 在新增一個方法 (Method),名叫 getHello()
程式碼片段如下:

@RequestMapping("/hello", method = [RequestMethod.GET])
fun getHello(@RequestParam("name", defaultValue = "World") name: String): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, $name")
}

比起剛剛的 Method 多了參數,只接受 GET 方法(使用其他方式存取會錯誤,因為沒定義)
不標明等於接收所有的方式

參數部分使用 @RequestParam 修飾字標明,標上參數名稱,
defaultValue 如果沒填資料的話的預設值為何
然後像剛才一樣,回傳一個 JSON 物件

方式+名稱 的組合不可重複,但可接受同名但不同的方式的 Method
例如這樣是合法的:

@RequestMapping("/hello")
fun hello(): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, world (from default method)!")
}

@RequestMapping("/hello", method = [RequestMethod.GET])
fun getHello(@RequestParam("name", defaultValue = "World") name: String): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, $name (from get)")
}

@RequestMapping("/hello", method = [RequestMethod.POST])
fun postHello(@RequestParam("name", defaultValue = "World") name: String): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, $name (from post)")
}

後面 Restful API 的時候會再提到。


錯誤處理

錯誤處理有二個部分,不太一樣:

  • 執行時出現 Exception 時,可自訂回傳的錯誤訊息
  • 當存取一個不存在的 API 時,預設回的訊息( 404 Not found 頁面)

處理 Exception 錯誤

先新增一個 GeneralErrorResponse 做為錯誤訊息的回應

GeneralErrorResponse.kt

package com.example.demo.model

import com.fasterxml.jackson.annotation.JsonInclude

@JsonInclude(JsonInclude.Include.NON_NULL)
data class GeneralErrorResponse(
    val message:String, 
    val requestUrl: String? = null, 
    val errorStackStace: String? = null
)

新增一個 ThrowableExtension.kt 新增一個 stackTraceAsString() 拿到 stackTrace 的字串

ThrowableExtension.kt

package com.example.demo.utils

import java.io.PrintWriter
import java.io.StringWriter

fun Throwable.stackTraceAsString(): String {
    val errors = StringWriter()
    this.printStackTrace(PrintWriter(errors))
    return errors.toString()
}

最後新增 GlobalExceptionHandler 用來處理全域的 API 錯誤問題

GlobalExceptionHandler.kt

package com.example.demo

import com.example.demo.model.GeneralErrorResponse
import com.example.demo.utils.stackTraceAsString
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.ResponseStatus
import javax.servlet.http.HttpServletRequest

@ControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(Throwable::class)
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    @ResponseBody
    fun handleException(request: HttpServletRequest, error: Exception): GeneralErrorResponse {
        return GeneralErrorResponse("Error occurred!", request.requestURL.toString(), error.stackTraceAsString())
    }
}

這邊使用了 @ControllerAdvice@ExceptionHandler 標示
當然不同情境有不同的用法,這邊示範回傳一個 JSON

最後在 Controller 新增一個測試用的 Exception 方法

@RequestMapping("/testErr")
fun testErr() {
    throw Exception("test exception")
}

然後瀏覽測試之。

處理 404 Not found 頁面

這裡處理當存取一個不存在的 API 時,預設回的資料。
我們再新增一個 Controller 名叫 MyErrorController 實作 ErrorController 介面,
範例如下:

MyErrorController.kt

package com.example.demo

import com.example.demo.model.GeneralErrorResponse
import org.springframework.boot.web.servlet.error.ErrorController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class MyErrorController : ErrorController {
    override fun getErrorPath(): String {
        return "/error"
    }

    @RequestMapping("/error")
    fun handleError(): GeneralErrorResponse {
        return GeneralErrorResponse("404 Error")
    }
}

這裡需實作 ErrorController 介面,定義錯誤發生的預設 Path 將走去哪裡
這裡使用 "/error"
然後綁定前述的值,回應一個預設訊息。


參考資料

Linux Server 伺服器建置筆記 (用 Ubuntu 設定基礎網路 & SSH伺服器)

這邊整理了一些手動 Linux server 伺服器安裝,需注意的一些事情與指令。
備忘一下以備不時之需。
(如果是設定雲端主機的話,部分步驟可以跳過,它預設都幫你建好了。)

製作可開機 USB (Bootable USB)

使用 UNetbootin 軟體

軟體下載:https://unetbootin.github.io/

選擇 USB drive,選擇 ISO 就可以了
針對目標機器做開機。

Mac 系統的話,可以使用 Etcher
軟體下載:https://www.balena.io/etcher/

做法差不多


選擇作業系統:

  • Debian 系列:可選擇 Ubuntu, Debian
  • RedHat 系列:可選擇 RHEL, CentOS, Fedora
  • BSD 系列:可選擇 FreeBSD
  • SUSE 系列:可選擇 OpenSUSE

前二項是筆者較為熟悉的,推薦 Ubuntu, Debian, CentOS 做為選項。

ISO 的版本很多:

  • Desktop ISO:有一個完整的 Live CD 可供試用
  • Server ISO:有預載一些伺服器使用的套件
  • Minimal ISO:只是檔案小,預設網路驅動了之後,大多都從網路上抓

不知怎麼選擇的話,預設就選 Desktop ISO
(以下撰文用 ubuntu 做示範)

網路指令相關

這邊列出常用的網路指令,如果網路不通的事情,當然要優先處理。

列出網路介面與 IP 位址

$ ip a
$ ip addr show
$ ifconfig

這幾個指令都可以,輸出格式稍有不同。

列出路由閘道 Gateway 設定

$ route -n

設定網路連線資訊

這邊介紹一個新東西:netplan
網路對它介紹不多,但個人覺得非常可以取代目前網路設定不方便的窘境。

假設你要設定的網路連線資訊如下:

  • 目標介面: eth0

  • IP 位址 (IP Address): 192.168.10.200

  • 子網路遮罩 (Netmask): 255.255.255.0 (/24)

  • 網路閘道 (Gateway): 192.168.10.1

  • 主要 DNS 為 8.8.8.8

  • 次要 DNS 為 168.95.1.1

(請根據你的自身環境修改,這裡只是舉例)

只要找到 /etc/netplan/01-netcfg.yaml 這個檔案並編輯

$ sudo vi /etc/netplan/01-netcfg.yaml

修改成類似以下內容:

network:
  version: 2
  renderer: networkd
  ethernets:
    eth0:
      addresses: [192.168.10.200/24]
      gateway4: 192.168.10.1
      nameservers:
        addresses: [8.8.8.8,168.95.1.1]
      dhcp4: no

(請根據你的自身環境修改,這裡只是舉例)

就這樣而已,省二、三個指令,簡單又直覺。

如果你要 dhcp (自動取得 IP 位址) 那更簡單了:

network:
  version: 2
  renderer: networkd
  ethernets:
    eth0:
      dhcp4: yes

然後存檔離開

執行一個很直覺的指令: netplan try

$ sudo netplan try
Do you want to keep these settings?

Press ENTER before the timeout to accept the new configuration
Changes will revert in 120 seconds

是否要保留設定?按 Enter 保留設定,不然 120 秒後會復原。
就跟切換螢幕解析度一樣簡單。

爾或者可以用 netplan apply 直接套用。

$ sudo netplan apply

(舊式) 設定 IP 位址 (IP Address)

$ sudo ip addr add 192.168.10.200/24 dev eth0

這邊用 192.168.10.200 做為例子,請修改成恰當的值。

(舊式) 設定網路閘道 Gateway

$ sudo route add default gw 192.168.10.1 eth0

這邊用 192.168.10.1 做為例子,請修改成恰當的值。

(舊式) 設定 DNS

$ sudo echo nameserver 8.8.8.8″ > /etc/resolv.conf

這邊用 8.8.8.8 的 Google DNS 做為例子,你也可以調整成你喜歡的。

DHCP Relase

釋放從 DHCP 取得的 IP 位址

$ sudo dhclient -r

指令等同 Windows 裡的 ipconfig /release

DHCP Renew

從 DHCP 重新取得新的 IP

$ sudo dhclient

指令等同 Windows 裡的 ipconfig /renew

啟動/關閉 網路介面 (ip 指令)

$ ip link set dev eth0 up
$ ip link set dev eth0 down

例如介面名稱為 eth0,請自行修改成合適的網路名稱。

啟動/關閉 網路介面 (ifconfig 指令)

$ /sbin/ifconfig eth0 up
$ /sbin/ifconfig eth0 down

例如介面名稱為 eth0,請自行修改成合適的網路名稱。

列出所有網路介面與狀態

$ ip link show
$ ifconfig -a

這二個都可以

檢查外部公有 IP (Public IP)

$ curl ipinfo.io/ip

一個簡單的指令可以查詢外部公有IP地址 (Public IP)

SSH 相關

安裝 SSH Server (應該預設就有安裝了)

應該預設就有安裝了,如果沒有安裝,請手動用指令安裝之。
(以下為 ubuntu 的指令)

$ sudo apt install -y ssh openssh-server

開機預設啟動 ssh

$ sudo systemctl enable ssh

啟動 ssh

$ sudo systemctl start ssh

查看 ssh 狀態

$ sudo systemctl status ssh

使用 ssh key 取代密碼登入

增加方便性也加強安全性,建議用 ssh key (pem) 檔案來登入 ssh。

產生 ssh key

$ ssh-keygen

指定檔案,例如 id_rsa 檔案(檔名可自訂)。
密碼 passphrase 可以留空

將會產生 id_rsa (私鑰) 與 id_rsa.pub (公鑰) 檔案。

接下來的步驟將是把您的公鑰複製到伺服器上(或者是把私鑰下載回使用者電腦上)。
使用者(你)透過電腦上的私鑰來做連線。

自動複製 ssh key

(在 Client 端執行此指令)
這個步驟是自動把您的公鑰複製到伺服器上。

$ ssh-copy-id -i ~/.ssh/id_rsa -p 22 [email protected]

如果不能運作也不用太糾結,等等有手動的方式。

運行結果:

/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/Users/user/.ssh/id_rsa.pub"
The authenticity of host '[192.168.10.200]:22 ([192.168.10.200]:22)' can't be established.
ECDSA key fingerprint is SHA256:wYmwxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcFme8.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
[email protected]'s password:

Number of key(s) added:        1

Now try logging into the machine, with:   "ssh -p '22' '[email protected]'"
and check to make sure that only the key(s) you wanted were added.

另外一個指令,作法相同。

$ cat ~/.ssh/id_rsa.pub | ssh -p 22 [email protected] "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

或者手動複製產生之公鑰 (PublicKey) 到伺服器的 ~/.ssh/authorized_keys 檔案。
(如果沒有 .ssh 隱藏資料夾與 authorized_keys 檔案,請自行建立。)

設定 SSH 關閉密碼登入

$ vi /etc/ssh/sshd_config

找到這行並修改

PasswordAuthentication no

設定免密碼 sudo

(這個步驟非必要)
在設定之前,先調整預設開啟的編輯器。
因為小弟長期習慣用 vim 所以用此指令先切換預設開啟的編輯器

$ sudo update-alternatives --config editor
There are 4 choices for the alternative editor (providing /usr/bin/editor).

  Selection    Path                Priority   Status
------------------------------------------------------------
* 0            /bin/nano            40        auto mode
  1            /bin/ed             -100       manual mode
  2            /bin/nano            40        manual mode
  3            /usr/bin/vim.basic   30        manual mode
  4            /usr/bin/vim.tiny    15        manual mode

Press <enter> to keep the current choice[*], or type selection number: 3
update-alternatives: using /usr/bin/vim.basic to provide /usr/bin/editor (editor) in manual mode

ubuntu 預設是開 nano 編輯器,可以用這個來修改
可以選擇 3 改用 vim 編輯器。

然後使用該指令編輯設定檔

$ sudo visudo

找到

%sudo   ALL=(ALL:ALL) ALL

把它改成

%sudo   ALL=(ALL:ALL) NOPASSWD: ALL

然後存檔離開。

連線 SSH

這個可以做為 bash script 以後方便使用。

$ ssh -i ~/.ssh/id_rsa -p 22 [email protected]

軟體更新

時時軟體更新、修補漏洞、修復 Bug 是很重要的,以下是一些常用的指令

更新套件庫清單(取得有哪些套件已更新)

$ sudo apt update -y

更新套件

$ sudo apt upgrade -y

確認版號

$ lsb_release -a

其實另外二個都可以,個人比較喜歡這個

$ cat /etc/os-release  
$ hostnamectl

列出 Linux 核心版本號

$ uname -r

整個大版本更新
例如從 ubuntu 20.04LTS 升到 ubuntu 22.04.2LTS

$ sudo do-release-upgrade

參考資料

https://www.cyberciti.biz/faq/ubuntu-linux-install-openssh-server/
https://www.digitalocean.com/community/tutorials/how-to-configure-ssh-key-based-authentication-on-a-linux-server
https://vitux.com/ubuntu-ip-address-management/
https://www.cyberciti.biz/faq/howto-linux-renew-dhcp-client-ip-address/
https://tldp.org/HOWTO/Linux+IPv6-HOWTO/ch05s02.html
https://www.cyberciti.biz/faq/upgrade-ubuntu-20-04-lts-to-22-04-lts/

[開箱分享] 設定淺色主題 (Light theme) – 感受 文石 Boox Mira 13.3″ 純黑白體驗

繼上一篇 Boox Mira 開箱文,這篇細節來講講如何在您的電腦做一些簡單的設定,
設定白色為背景的主題 (Light theme),讓對比度提高,增加閱讀與寫作的體驗。
這邊用小弟常用的軟體,Sublime text、VS code、Intelij 做介紹。


此系列文:


就讓我們一起來感受 Boox Mira 純黑白體驗吧!

Mira app 本身設定

設定圖

Mira螢幕解析度

分享一下我的設定值:

  • 顯示模式 (Refresh speed):普通 (適合網頁/文字閱讀)
  • 刷新速度(Refresh speed):6
  • 深色增強 (對比度) (Dark Color Enhancement):10
  • 淺色過濾 (Light Color Filiter):50
  • 冷光 (Color light):0 (不開)
  • 暖光(Warm light):0 (不開)
  • 定時自動全刷:120 秒
  • 螢幕解析度:1280 x 960

這邊講一下 Mira 配套的 app 一些能調整的細節

  • 顯示模式
    - 一般模式 (個人推薦)
    - 影片模式 (防止抖動使用)
    - 文字模式 (黑白過濾,只剩黑與白)
    - 圖片模式 (採最高畫質輸出,手動刷新)

刷新模式

這個您可以每個都試試看,
文字模式猜測是關閉灰階演算法,直接一個臨界值 (threshold) 就分成黑與白,理論上對比度最高,但就畫面比較生硬。
圖片模式屬於靜態顯示使用,關閉刷新,採最高畫質輸出,如果要展示餐廳餐單之類的可以選擇。

我個人覺得一般模式最好,如果要看影片,不仿選擇影片模式,減少灰色部分因灰階演算法造成的閃爍。
但影片模式的代價就是比較糊,文字類的就沒那麼清楚。

  • 刷新速度
    字面上的意思,數值越大,反應速度越快,相對的畫質越差,可以調整到滑鼠滑過去覺得順暢的刷新程度
  • 黑色加強
    字面上的意思,數值越大,偏黑色的灰色顯示就會就會黑色,為了增加對比度。
  • 白色過濾
    數值越高,灰色的地方就會被過濾成白色。
  • 冷暖色光調整
    就是調整面板的背光,冷暖雙色可以自由混和,也可以連動,也可以不開,
    像我就是不開這派的。

刷新模式差異

一般模式看文字內容

屬於細節均橫,圖片也不會太誇張的配置。

細節特寫,對比很清楚

文字模式看文字內容

乍看下沒什麼差異,但工具列已經黑糊在一起了。

影片模式看文字內容

細節特寫,實在有點糊

一般模式看圖片

文字模式看圖片

二個字 — 悲劇。
文字模式還是做它擅長的事情就好了。

影片模式看圖片

畫面整個變均衡了

細節特寫,這個石頭細節很棒!

系統設定 (Mac)

因為小弟主要是使用 Mac,
以下用 Mac 的方式跟大家分享。

左上蘋果按鈕 > System Preferences

General > Appearance: Light
使用淺色主題

Accessibility > Display

  • Reduce motion 減少動畫
  • Reduce transparency 減少透明度效果(增加對比度)

這二項可以視情況開啟

Accessibility > Cursor

  • Cursor size 這邊可以調整游標大小(如果有需要的話)

Display > Night Shift: Off
記得不要打開夜覽 (Night Shift) 功能,因為它會調整螢幕顏色,
正白色不會是正白色,而變成灰色,就會霧霧的

常用軟體類

Visual studio code

微軟出品的,也是資訊界大家很推薦好用的編輯器。就算不是資訊工程師,拿來文字處理也是好用。

下載連結:https://code.visualstudio.com/

分享一下它的設定。

在 Code > Preferences > Settings
會開出設定視窗

Workbench > Appearance
Color Theme 改成以下都可以

  • Light+ (default light)
  • Light (Visual Studio)
  • Quiet Light

就會是淺色系的編輯器了。

Sublime Text

小弟常用的一個很實用的編輯器,也是很老牌的編輯器。
就算不是工程師,拿來文字處理也是好用。
時不時會跳出一下使用購買的提示,免費就可以使用它全部的功能,如果喜歡的他們的軟體的話,當然付費支持一下摟!

軟體下載連結:https://www.sublimetext.com/

分享一下它的設定。

在 Sublime Text > Preferences > Select Color Scheme

會出現一個選單,選擇

  • Breakers
  • Celeste
  • Sixteen

這三個都可以,都是淺色主題,差異在語法標示 (Syntax highlighting) 的顏色不同,
但在 Mira 底下看起來應該是接近的(都黑白的嘛 😂 )

在電子紙的部分當然盡可能黑白對比度高,以白底背景為主的介面,會更好

InteliJ IDEA

這個不用多說,在資訊界很有名的付費編緝器之一。
強大的搜尋處理、語意提示、自動完成…等等,講都講不完。

軟體下載連結:https://www.jetbrains.com/idea/

分享一下它的設定。

在 InteliJ IDEA > Preferences

Appearance & Behavior > Appearance

Theme: macOS Light

iTerm2 終端機

這個在 Mac 底下,開放原始碼的終端機,比起內建的終端機,有更多的客製選項可以使用。

軟體下載連結:https://iterm2.com/

分享一下它的設定。

在 iTerm2 > Preferences 的地方

Profile > Colors 的地方,可以自由調整顏色

我們選最快速的 Color Presets,選擇以下二種都可以

  • Light Background
  • Tango Light

Mozilla Firefox 瀏覽器

這個大家就熟悉了,除了 Google chrome,另一個效能與注重隱私的瀏覽器

軟體下載連結:https://www.mozilla.org/

這個擴充外掛是網友推薦,就看你要不要使用。
因為小弟有時候會調整網頁畫面,可以選擇性開啟。

Toggle Website Colors (Global)
https://addons.mozilla.org/en-US/firefox/addon/toggleglobalcolors/

它可以幫你把非白色的 背景 與 文字 改成 白底黑字 增加對比度。

調整前

示範網頁連結在此
很明顯有一個很大片的黑色底

調整後

找到 Toggle-Website-Colors 按鈕之後,無腦按下去

白底就來了。

Google chrome 瀏覽器 & Mircosoft Edge 瀏覽器

一個是 Google 出的瀏覽器,一個是微軟出的瀏覽器。
比較少人知道的是,它屬於同一個 Chromium 核心所以一起講。
而且後者 Edge 可以安裝前者的外掛,就可以一起講了。

Google Chrome 下載連結:https://www.google.com/intl/zh-TW/chrome/
Mircosoft Edge 下載連結:https://www.microsoft.com/zh-tw/edge

小弟這邊找了很久,有沒有類似 Toggle Website Colors (Global) 的擴充外掛呢?
小弟找到最接近的就是這個:

Change Colors
https://chrome.google.com/webstore/detail/change-colors/ageghplgcapnfpdhapeemolbmfccclke

但這個需要設定一下,他預設是黑底白字,需調整為白底黑字
在有需要的時候可以套用

這邊有二個地方需要手動調整

  • Background color: FFFFFF
  • Text color: 000000

設定背景跟文字的顏色,背景為白,文字為黑。其他的設定你可以視情況調整。

調整前

示範網頁連結在此

調整後

在你需要觀看的頁面底下,找到 Change color 的外掛按鈕,按下 Apply override on this page,然後 重新整理 網頁。

就會變成這樣

在電子紙螢幕看起來對比度更高。

如果要取消,就找到 Change color 的外掛按鈕,按下 Remove override on this page,然後 重新整理 網頁。

(他這個外掛似乎不會手動幫你重新整理,所以要重新整理網頁才會看到變更。)

以上就是小弟的設定值,希望你也可以跟我一樣調整出自己順手的設定。

參考資料

https://jingyi.blog/posts/mira_color_schemes/

[開箱分享] 文石 Boox Mira 13.3吋 電子紙顯示器 – 神級刷新的 E-ink 電子紙螢幕 上手體驗

boox mira 標題圖

不管工作或娛樂,電腦對於小弟我是生活不可或缺的工具,身為一個文字工作者(資訊工程師算是某種文字工作者吧?),平時工作寫程式、看網路文件、寫部落格,每天盯著盯著螢幕 10-12hr,眼睛擔負重責大任。除了日常的休息與保養之外,如果有什麼護眼成熟的產品,會想買來使用。電子紙是在之前初次接觸的時候就知道的產品,從後面幾代的 Kindle(小弟第一台的電子紙產品是 2020 年推出的 Amazon Kindle Keyboard 3gen),到近期的電子紙閱讀器,小弟一直有在關注電子紙的發展。


此系列文:


電子紙,初期見到他的感覺是 — 真的好像紙!,不自發光,本身只靠環境光源做閱讀,很優異的產品。(朋友第一次看甚至覺得很像假的 😂 )
但馬上就會發現它的缺點 — 刷新速度慢!相對於發展成熟的 LCD 液晶螢幕來說,刷新速度是用秒做為計算的,越來越發展直到看到這產品,這刷新速度優異到,快要媲美 LCD 的刷新速度了,當然是講「快要」,但至少到堪用的程度了,從當初官方測試影片釋出的時候我就心動下訂了,他們把電子紙推到另外一個新的境界了。
雖然還是黑白的,反應速度幾乎快跟的液晶無異了,就是這款文石 Boox Mira 13.3" 電子紙螢幕顯示器。
如今開放預購,接單生產,到真的拿到手上,也使用一陣子,到底是怎樣的體驗呢?
讓我們繼續看下去。

快速開箱

所有物品一覽

原廠似乎就一個大盒子,裡面有:

  • Mira 本體
  • Mini HDMI to HDMI 的線
  • Type-C to Type-C 的線
  • 說明書&保證書
  • 磁吸保護殼

原廠盒

Mira-開箱02

配件

設計很簡潔大方,利用黑色打出質感,磁吸保護殼顏色我也很喜歡,很有質感。

機器本體就是前鋁合金原色的感覺,自從入手蘋果 Macbook 之後,對於 Unibody 那種鋁合金金屬原色的產品都會愛不釋手 😅
有一點要注意的是,他的螢幕正面,不是全平面,是有做一個很窄的圓角框邊的設計,遠看不至於發現不是全平面。個人覺得,這樣的窄框邊設計,減少了一片電子紙螢幕本身透明面料的選擇減少透光率之外,如果不小心正面落下的話,不至於首先嗑碰到螢幕。

賣家附的有:

  • 不織布提袋
  • Mira 保護套
  • 小炫風遙控器
  • 螢幕支架座

平板支架01

平板支架02

平板支架03

非常可圈可點的是他附的支架座,有一般的夾具 與 加大的夾具,可以完整把 Mira 夾起來,角度調整也多,而且底座也很穩固。比較適合在室內使用,帶出去就稍微重了些。
保護套也很實用,保護力算蠻好的,不用另外去找了,很貼心。
小炫風遙控器的部分,目前這裏用不上,留到之後再評測。

硬體規格

規格

硬體規格(整理自文石官網):

  • 螢幕:13.3英吋柔性電子墨水螢幕
  • 解析度:1650×2200(207ppi)
  • 前光:冷暖雙色溫前光
  • 觸控類型:電容觸控
  • 介面:
    • Mini HDMI x 1
    • USB-C x 2
  • 螢幕掛架:VESA標準介面 (75x75mm)
  • 按鍵:2 個按鍵 + 1 個撥輪按鍵
  • 尺寸:308.4 x 231 x 5.8mm
  • 重量:約590g
  • 材質:全鋁合金機身(銀色)

Mira開箱-接口

這台 Boox Mira 電子紙螢幕,它有二個 USB Type-C 的插孔,對於都是 Type-C 的 Macbook 來說很友好,
一條 Type-C to Type-C 的線就可以直上了。

如果是別的機種,原廠有附 mini HDMI to HDMI 的線,也可以供電後直接插上使用。

快捷鍵就一個小撥動開關跟二顆按鈕,一顆按鈕為「重刷鍵」按下去會強制刷新整個螢幕。
一個搭配撥輪開關使用,可以調整 對比度、冷光前光、暖光前光。
(截自截稿,都還沒有辦法不用 app 調整來刷新模式)

他的保護殼屬於磁吸式的保護殼,實際使用上,我覺得應該要另外找架子來撐會比較理想。
雖然他有橡膠做止滑,但有時候他會滑掉。

如果有安裝螢幕架的需求,他也有提供標準的 VESA 來使用,
個人沒有安裝過,就無從評測。

實測使用

文章

經小弟電腦實測,
跟我的 2018 年的 Macbook pro 13" 很相容,跟官方實測影片完全一致,很理想。
但是跟 2020 年的 Macbook 13" M1 在灰色區域部分會有明顯閃爍現象(不知道是不是個案),已經反應給文石 Boox 請他們工程師查看
不過經由調整,加強對比度等,到一種還可以接受能使用的程度。

Mac 電腦的部分,需要安裝他的對應桌面版 app,藉由 app 來調整刷新模式

Mira開箱-設定圖

  • 一般模式 (個人推薦)
  • 影片模式 (防止抖動使用)
  • 文字模式 (黑白過濾,只剩黑與白)
  • 圖片模式 (採最高畫質輸出,手動刷新)

刷新速度、黑色加強、白色過濾、冷暖色光調整…等
都可以在 app 裡面調整

另外他可以觸控。個人沒有很常用就是
最近它 app 加入了自動刷新定時器,便利性又在更近一步了

您可以把佈景主題變成白色,電子紙在白色下比較有發揮。

它能不能看電影呢?

可以,播放是順暢的。看你大概不會想看。

實測-電影
(示範影片:末日列車 https://youtu.be/lGcJL6TG5cA

人物跟背景會黑成一團,看電影這種事情還是移到液晶螢幕上吧。

實測-動畫
(示範影片:一拳超人 https://youtu.be/2JAElThbKrI

實測動畫的部分,因為以線條為主的,清晰度來說還勉強能看。
(示範影片:Spring Boot Tutorial https://youtu.be/9SGDpanrc8U

實測-課程

實測線上課程的部分,基本上如果是投影片為主的話,都還算能看。
這邊也很明顯看到很多白色斑點,不是壞掉,而是演講者瘋狂移動滑鼠所造成的殘影。
如果是程式碼講解,基本上又回到黑底白字的情況的話,一樣很容易糊。

其他實測,
接上 Samsung galaxy S9 後會自動 DeX 模式,可以正常觸控,但螢幕比例跑掉,無法調整解析度
可以勉強使用,但整體體驗很差,就不多述了

疊疊樂

以下跟是 我的 Macbook pro 13"(左)跟 Boox Mira 13.3"(右) 做一個比較

疊疊樂-左右

Boox Mira(下)稍微比 Macbook pro(上)大了一些

疊疊樂 - 上方

厚度來說,不會差太多。

另一個角度。

缺點 (能改善之處)

目前硬體上的限制缺點:

  • 螢幕太小(只是存粹小弟想買 Mira pro 25.3 吋的版本 😂 )
  • 黑白(?) (算是對某些人來說是個缺點,但個人覺得還好)
  • 殘影

嗯對,殘影是會有的,只能透過定時刷新來解決這個問題。

殘影的圖片

個人覺得能改善之處:

  • 硬體鍵調整刷新模式,顏色過濾等選項,因為還是有可能有部分裝置不能連接 app
  • 可能需要動畫過濾的選項(或者避免螢幕閃爍之類的選項)
    - 加上輸出邊界縮放調整的選項,因為 Mac 接上螢幕時,邊角有部分一點點遮擋,雖不影響使用

總而言之,是個很棒的產品,
很適合文字工作者,需要文思泉湧的打字的時候,或者是像小弟我,需要時不時看很多網路參考文件,
其實我買電子紙閱讀器,使用最多的也是網頁瀏覽器,來源都是網路參考文件,不管是官方說明文件,或者是各種知識內容。
他會是你很理想的第二個螢幕。

[Q & A] 要不要貼保護貼?

多一層保護貼,會減少透光率,
如果要貼的話,可以貼霧面保護貼,減少反光。
我個人是沒有貼。

Mira 正面

Mira 反面

[Q & A] 外出攜帶保護怎麼樣

它原廠有附一個磁吸保護套,還蠻有質感的。
另外賣家有附一個專屬收納盒,這雙重保護個人覺得很夠用。
但還是老話一句,電子紙貴在它的螢幕,它不耐摔、不耐碰撞、不耐擠壓。
希望能好好呵護它。

另外,因為 13.3 吋的關係
(加上保護套比我的 13 吋 Macbook 再稍大一些),放進包會有一點稍大。

Mira開箱01

[Q & A] 關於線材,Type-C 接上螢幕沒反應?

直接講結論,請使用原廠附的那條黑色粗的那條線
小弟已經實測過,
蘋果的那條白色的 Type-C to Type-C 線,是確定無法辦法使用的。
(無法使用的情況就是,只會看到可愛的小老鼠睡覺的樣子,螢幕點不起來)

另外,如果要自己買線的話,可以挑有 E-mark 晶片的線材,可能可以解決問題
另外,它似乎 Type-A to Type-C 可以使用,但個人不確定供電是否足夠。
最好是外接電源使用。

我的設定值

Mira開箱-設定圖

分享一下我的設定值:

  • 顯示模式:普通 (適合網頁/文字閱讀)
  • 刷新速度:6
  • 深色增強 (對比度):10
  • 淺色過濾:50
  • 冷光:0 (不開)
  • 暖光:0 (不開)
  • 定時自動全刷:120 秒
  • 螢幕解析度:1280 x 960

其餘沒特別調整

參考資料

http://zh.boox.com/mira/
https://jingyi.blog/posts/mira_display_setup/
https://jingyi.blog/posts/mira_first_expression/

[iOS] 跟 Sign in with Apple 的愛恨情仇

最近終於研究出如何使用 Sign in with Apple 包含前後端的串接,分享給大家。
(本文同步於 iPlayground 2020 演講)


投影片:

iPlayground 影音版:https://www.youtube.com/watch?v=DwXhZwRDlzk


為什麼要串?

在 Apple 開發者大會 WWDC 2019 發表了 Sign in with Apple 的功能,iOS 13 後皆支援。

主要二個重點:

  • 使用 Apple ID 登入,強化保護您的隱私權
  • 當用戶不願給出自己的 E-mail 時,蘋果可以生成一組
    「虛擬 E-mail」給用戶使用,
    「虛擬 E-mail」收到的資訊,蘋果會代轉給用戶的「真實 E-mail」

官方消息指出,從 2019 年 9 月 12 日開始,新上架的 APP 需設置 Apple ID 登入;

在 2020 年 4 月之後,蘋果直接強制要求,
只要您的 app 有支援第三方登入 (例如:Facebook 登入、Google 登入、Twitter 登入…等)
就「一定要」支援 Sign in with Apple (硬要多一顆按鈕),相當於強迫中獎的概念。

基本上只要有帳號系統的服務網站,幾乎全部得加。

否則會怎麼樣呢? app 送審不會過,新 app 無法上架,新的 app 更新無法推上架提供更新。

串接 Sign in with Apple 勢在必行,因為 App Store 是 Apple 在管,官方擁有至高無上的權力。

Apple Sign In 的原理

Apple Sign In 主要接近於標準的 OAuth 流程,我們主要分成 網站 跟 App 二個部分來說:

App 登入

  1. 在 App 中,使用者按下 Sign in with Apple 按鈕,呼叫相關 AuthenticationServices framework 的相關函式,讓 iOS 系統跳出登入確認
  2. iOS 系統跳出 AppleId 登入確認,利用 TouchId 指紋辨識或 FaceId 臉部辨識(可選擇是否隱藏其 E-mail)
  3. 登入授權成功,系統呼叫 didCompleteWithAuthorization,我們處理該 Callback 將相關參數,用 API 傳回自己的網站
    (如果不做伺服器驗證的話,到這邊就結束了,但不建議)
  4. 在我們的網站 API 收到相關參數後,拿著參數向 Apple 驗證,並取得該使用者資訊
  5. 拿到使用者資訊之後,進行驗證登入,如果沒有帳號者會自動創立帳號

網站登入

  1. 在我們的網站,使用者按下 Sign in with Apple 按鈕,呼叫指定網址,其中帶入 Redirect URI 轉址回跳我們網站的網址,瀏覽 Apple 網站進行登入與授權
  2. 使用者在 Apple 網站進行登入,輸入 AppleId 的帳號密碼,
    並授權開發商授權允許(可選擇是否隱藏其 E-mail )
  3. 登入授權成功,轉跳回我們的網站,並附上一些的參數
  4. 我們的後端,拿著 Apple 傳回的參數向 Apple 驗證,並取得該使用者資訊
  5. 拿到使用者資訊之後,進行驗證登入,如果沒有帳號者會自動創立帳號

流程有一點接近但不太一樣,怎麼向 Apple 驗證這個之後會說。

虛擬 E-mail

剛剛有提到,當用戶不願給出自己的 E-mail 時,蘋果可以生成一組
虛擬 E-mail 給用戶使用,
這個虛擬 E-mail 是 亂碼@privaterelay.appleid.com
它是每個 App 專屬的,每一組 E-mail 都不一樣,
蘋果會驗證寄件人與寄件來源(不是什麼信件蘋果都會轉)

刪掉重裝又會產生一組新的虛擬 E-mail,讓使用者可以在他的設定頁,充分控制自己的個資露出於否。

但要實現這個功能,需要設定寄件人,讓指定的寄件人可以寄給使用者。

如果您是用自訂網域的話,則要綁定您的網域,蘋果他會用 SPF (Sender Policy Framework) 來驗證寄件來源。換句話說,如果要讓這個 虛擬 E-mail 能夠正常收到您寄的信,看你網域常用發信服務是誰,在您的網域 DNS 設定對應的 SPF DNS Record。

蘋果開發者網站設定

在 Apple 的開發者網站 ( https://developer.apple.com/ ) 當然要做很多設定

一樣分成網站 跟 App 二個部分來說:

App 部分

在 Certificates, Identifiers & Profiles -> Identifiers -> App IDs

按旁邊 + 號,選擇 App ID,選擇 App (不是 App Clip)
來創建您的 App ID(如果已經建立的話就修改它)

  • Description
    這裏填入您的 App 名字,
  • Bundle ID
    選擇 Explicit ,並填入 Xcode 裡的 Bundle identifier

(這應該是一般 IOS 開發者會做的事情)

今天要勾選 Sign In with Apple,並按下 Edit

  • Sign In with Apple: App ID Configuration
    留預設值 Enable as a primary App ID 即可
  • Server to Server Notification Endpoint
    這裡要填入一個網址,接收處理從 Apple 傳來的通知,後面會提到

網站部分

在 Certificates, Identifiers & Profiles -> Identifiers -> Service IDs

按旁邊 + 號,選擇 Service ID 來創建您的 Service ID
這裡有二個選項要填:

  • Description
    這裏填入您網站的名字
    (注意:這個值會顯示在前台網站給使用者看到)
  • Identifier
    你可以隨意取一個,作為辨識用
  • 勾選 Sign in with Apple 並按旁邊的 Configure

在 Web Authentication Configuration 頁面

  • Primary App ID
    選擇你主要 App 的 App ID
  • Register Website URLs

    • Domains and Subdomains
      這裏填入您網站的網域
      (注意:你的網站必須要有 https,不可用 localhost 或者 IP ,否則這裡不會過)

    • Return URLs
      這裡填入回跳用的 Redirect URI
      (一樣的規則,你的網站必須要有 https,不可用 localhost 或者 IP ,否則這裡不會過)

    如果是測試用的話,可以使用測試用的 Redirect URI:

    https://example-app.com/redirect

    (他是一個合法的網站,不是亂寫唬爛的)
    轉導到 example-app.com 之後藉由您手動複製貼上的方式把回傳值帶到你本機的測試程式中

Sign Key 驗證金鑰

我們需要建立一個 Sign Key 在等一下跟蘋果 API 做驗證使用,這部分因為網站跟 App 驗證流程後半段是一樣的,不管支援哪一個部分都要做。

在 Certificates, Identifiers & Profiles -> Keys

按旁邊 + 號,建立一個 Key

  • Key name
    可以自行取名
  • 勾選 Sign in with Apple 並按旁邊的 Configure
    • Primary App ID
      選擇主要 App 使用的 App ID
    • Grouped App IDs
      取名會把網站跟 App 群組綁在一起

按下 Continue 之後會讓你下載一個私鑰 p8 檔案,
注意這只能被下載一次,請好好保存。
如果不見的話就只能再重新產生一個。

設定寄件人、寄件網域

在 Certificates, Identifiers & Profiles -> More -> Sign in with Apple for Email Communication

按旁邊 + 號,輸入您的自訂網域與寄件人。

寄件人可以設定 Gmail 會直接通過。

如果自訂網域,需設定 SPF (DNS Sender Policy Freamwork)

設定 SPF

如果您是用自訂網域的話,則要綁定您的網域,蘋果他會用 SPF (Sender Policy Framework) 來驗證寄件來源。換句話說,如果要讓這個 虛擬 E-mail 能夠正常收到您寄的信,看你網域常用發信服務是誰,在您的網域 DNS 設定對應的 SPF DNS Record。

這遍設定各家有點不一樣

需要新增一個 TXT Record
值為

"v=spf1 include:_spf.google.com include:sendgrid.net include:amazonses.com ~all"

這個是範例值,裡面包含三個郵件服務 Email Service Provider (ESP)

  • Google (Gsuite) (Gmail)
  • SendGrid
  • Amazon SES

看您的郵件服務是哪一家,找到那家業者,照這個方式設定您允許的郵件服務存取您的網域

做一個簡單整理

  • CLIENT ID
    它可以是 App ID (也就是 Bundle ID) 也可以是 Service ID。

    • 如果要 App 端做登入,它就會是 App ID (也就是 Bundle ID)。
    • 如果要 網站端做登入,它就會是 Service ID。
  • REDIRECT URI
    OAuth 之後要轉跳的網址

    • App 的部分在 App ID 裡面做設定
      Certificates, Identifiers & Profiles -> Identifiers -> App IDs -> 您的 App ID -> Configure -> Return URLs
    • 網站的部分在 Service ID 裡面做設定
      Certificates, Identifiers & Profiles -> Identifiers -> Service IDs -> 您的 Service ID -> Configure -> Return URLs
  • TEAM ID
    你的開發者帳號 Team ID,這可以在你的右上角看到
    進去 Certificates, Identifiers & Profiles -> Identifiers -> App IDs -> 您的 App -> App ID Prefix 可以看見

  • KEY ID
    您建立驗證的 Sign Key 的 Key ID
    在 Certificates, Identifiers & Profiles -> Keys -> 您的 Apple Sign Key -> View Key Details -> Key ID 可以看到

  • SIGN KEY
    剛剛產生的 Sign Key 私鑰( p8 檔案 )

App 端實作

這裏你可以使用 Apple 所使用的 Sign In with Apple 按鈕,
Sign In with Apple 的按鈕 這裡

這裡有按鈕樣式規範與素材:
https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/overview/buttons/

我使用的是直接呼叫的方式

import Foundation
import AuthenticationServices

class AppleSignInManager: NSObject {
    var currentView: UIView?

    func signIn(currentView: UIView) {
        guard #available(iOS 13.0, *) else { return }
        self.currentView = currentView
        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()
        request.requestedScopes = [.email, .fullName]
        request.nonce = "[NONCE]"
        request.state = "[STATE]"
        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()
    }
}

這邊我寫了一個 AppleSignInManager 來控制整個流程,做一個 signIn() 的 method 呼叫 Sign in with Apple。

要設定

  • state:一個您設定的,辨識用的字串
  • nonce:一個您產生的,辨識用的亂碼

來避免 CRSF 跨網域攻擊

extension AppleSignInManager: ASAuthorizationControllerDelegate {
    @available(iOS 13.0, *)
    func authorizationController(controller: ASAuthorizationController, 
               didCompleteWithError error: Error) {
        // Handle error
        print(error)
    }

    @available(iOS 13.0, *)
    func authorizationController(controller: ASAuthorizationController, 
               didCompleteWithAuthorization authorization: ASAuthorization) {
        guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { return }
        // Post identityToken & authorizationCode to your server
        print(String(decoding: credential.identityToken ?? Data.init(), as: UTF8.self))
        print(String(decoding: credential.authorizationCode ?? Data.init(), as: UTF8.self))
    }
}

這邊要實作一個 ASAuthorizationControllerDelegate,處理登入成功、登入失敗之後的動作,
登入成功要把 identityTokenauthorizationCode 等資訊 回傳給您的伺服器,繼續做驗證。

extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
    @available(iOS 13.0, *)
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return self.currentView?.window ?? UIApplication.shared.keyWindow!
    }
}

這邊要實作一個 ASAuthorizationControllerPresentationContextProviding,回傳目前當下 ViewController 的 window 給它,它會在 Sign in with Apple 呼叫後,把系統的畫面放在它上面。

網站前端實作

文件在這:
https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple

你可以使用蘋果他預設給你的按鈕,

  • 字樣有 Sign in with Apple 或者 Continue with Apple 二種(包含多國語言)可以選
  • 樣式有 黑色白色 二種樣式,還有要邊框 (border) 與否可以選

或者你可以照個他的規範,自訂一個按鈕來用

按鈕樣式的規範與素材在這:
https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/overview/buttons/

OAuth 跳轉模式有二種可以選:

  • 直接頁面轉跳
  • 開新視窗(彈窗)登入後關閉(一般 popup window 的做法)

前者較簡單,後者要自行處理流程

文件上提到的寫法就有二種,不過意思是一樣的:

<html>
    <head>
    </head>
    <body>
        <script type="text/javascript" src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
        <div id="appleid-signin" data-color="black" data-border="true" data-type="sign in"></div>
        <script type="text/javascript">
            AppleID.auth.init({
                clientId : '[CLIENT_ID]',
                scope : '[SCOPES]',
                redirectURI : '[REDIRECT_URI]',
                state : '[STATE]',
                nonce : '[NONCE]',
                usePopup : true //or false defaults to false
            });
        </script>
    </body>
</html>
  • clientId:這遍填入你的 Services ID
  • scope:我都填固定值 name email (注意:中間有空格)
  • redirectURI:填入登入成功後要轉跳的網址,必須是 https 不能為 localhost 或者 IP
  • state:一個您設定的,辨識用的字串
  • nonce:一個您產生的,辨識用的亂碼
  • usePopup:是否用開新視窗(彈窗)登入,預設值是否

我使用的是自訂按鈕搭配 JavaScript 直接呼叫的方式:

async function doAppleSignIn() {
    try {
        const data = await AppleID.auth.signIn();
        console.log(data);
    } catch (error) {
        //handle error.
    }
}

上面的 AppleID.auth.signIn() 它會啟動整個 OAuth 流程。
如果是使用彈窗模式的話,會收到回傳的內容,如果是一般模式的話,會用頁面轉跳的。

成功登入時,如果是用轉跳的方式,會轉跳你設定的 REDIRECT_URI 並且傳入幾個參數供使用。

  • code
    單次使用的 Authentication code (效期只有 5 分鐘)

  • id_token
    identityToken 是一個包含用戶資訊的 JSON web token (JWT)

  • state
    你定義的字串

  • user
    一些 firstName, lastName, email 等使用者資料
    (不過蘋果這麼注重用戶隱私,也拿不到什麼資料)

如果是開新視窗登入,需要另外處理事件

//Listen for authorization success
document.addEventListener('AppleIDSignInOnSuccess', (data) => {
     //handle successful response
});
//Listen for authorization failures
document.addEventListener('AppleIDSignInOnFailure', (error) => {
     //handle error.
});

這裡你可以打你自己設計的 API,
identityTokenauthorizationCode 傳回給你自己的伺服器繼續做驗證

網站後端實作

JWT (JSON Web Token)

Apple Sign In 的資料交換主要使用 JWT (JSON Web Token) 的格式
(更明確點說,他是一個 JSON Web Signatures (JWS) 的格式)

在這之前,我們先釐清一下一些相關名詞:

  • RFC 7519 – JSON Web Token(JWT)
    定義了 header 內容與 claim 內容,以及 token 的相關規範

    • RFC 7515 – JSON Web Signature(JWS)
      定義如何做帶有簽章的 token
    • RFC 7516 – JSON Web Encryption(JWE)
      定義內容加密的 token
  • RFC 7517 – JSON Web Key(JWK)
    定義金鑰的格式
  • RFC 7518 – JSON Web Algorithms(JWA)
    定義加解密的演算法

所以

  • JWS 與 JWE 都是屬於 JWT 的一種。
  • 如果沒特別說明,則 JWT 皆是指 JWS。

我們再來說明一下什麼是 JWT

JWT 的全名是 JSON Web Token,是一種基於 JSON 的開放標準(RFC 7519),它定義了一種簡潔(compact)且自包含(self-contained)的方式,用於在雙方之間安全地將訊息作為 JSON 物件傳輸。而這個訊息是經過數位簽章(Digital Signature),因此可以被驗證及信任。可以使用 密碼(經過 HMAC 演算法) 或用一對 公鑰/私鑰(經過 RSA 或 ECDSA 演算法) 來對 JWT 進行簽章。

它是用 .(點)來分隔,主要有三個部分 base64UrlDecode 的內容:

  • Header
  • Payload
  • Signature/Encryption data

前二者用 Base64UrlDecode 解碼後,各會是一個 JSON 資料,
而 signature 故名思義就是一個用演算法算出來的 Hash

展開說明如下:

  • Header

    • alg
      必要欄位,對此 JWT 進行簽章、加解密的主要演算法 (JWA)。
      (這個名字叫做 JWA (JSON Web Algorithms) )
      這裡列出幾個常見的:

      • HS256 (HMAC-SHA256)
      • RS256 (RSA-SHA256)
      • ES256 (ECDSA-SHA256)

      第一項只有單向由同一把金鑰做雜湊 (Hash)。
      而後二者為非對稱式加解密,由私鑰進行簽名,由公鑰進行驗證。

    • typ
      JWT 本身的媒體類型,在 Sign In with Apple 這裡,
      我們使用預設值 JWT

    • kid
      這邊是 Apple 定義的 Sign Key 的 Key ID。

  • Payload
    • iss
      Issuer 的簡稱,表示發行者。
    • aud
      Audience 的簡稱,表示接收者。
    • iat
      Issued at (time) 的簡稱,即該 JWT 發行的時間,用 Unix timestamp 表示(POSIX 定義的自紀元以來的秒數)。
    • exp
      Expiration (time) 的簡稱,即該 JWT 過期的時間,格式一樣為 Unix timestamp。
    • sub
      Subject 的簡稱,用字串(case-sensitive) 或 URI 表示這個 JWT 所夾帶的唯一識別訊息。

怎麼驗證?

驗證方式有二種方式:

  • identityToken 用 JWT 的格式定義來驗證是否為 Apple 所簽發的
  • authorizationCode 用 OAuth 的機制向 Apple 伺服器交換並要求 Access Token

前後者不衝突,也可以二者都做,看你的需求。

驗證 IdentityToken

其實從 app 端拿到的 identityToken 它本身也是一個 JWT 格式

payload 用 base64UrlDecode 解開後可以得到類似以下的資料

Header 部分
{
  "kid": "86D88Kf",
  "alg": "RS256"
}

你可以看到他是用 RS256 (RSA-SHA256) 做加密簽章的,
由蘋果伺服器所擁有的私鑰進行簽名,由蘋果提供的 API 取得公鑰進行驗證。
它的 Key ID 為 86D88Kf (辨識是蘋果哪一把 Key 簽的,這等下會說)

Payload 部分
{
  "iss": "https://appleid.apple.com",
  "aud": "com.your.app.id",
  "exp": 1596621649,
  "iat": 1596621049,
  "sub": "001451.3dc436155xxxxxxxxxxxxxxxxxxxx59f.0447",
  "c_hash": "iUqI9Vyxxxxxxxxxg-CyoA",
  "email": "[email protected]",
  "email_verified": "true",
  "is_private_email": "true",
  "auth_time": 1596621049,
  "nonce_supported": true
}

取重點說明:

  • iss:發行者(issuer)
    https://appleid.apple.com,蘋果的伺服器
  • aud:接收者(audience)
    com.your.app.id (您的 App Id)
  • sub:Subject 主題的值,是使用者辨識唯一識別碼
    (可作為 使用者 ID 當做判斷依據)
  • email:使用者的 Email,範例值為虛擬 E-mail,
    如果用戶選擇不隱藏 E-mail,這裡就會顯示真實用戶的 E-mail
  • exp:過期時間
Signature 部份

從 Header 可以知道,
蘋果是用 86D88Kf 這把私鑰用 RS256 (RSA-SHA256) 簽的,等下取得該 86D88Kf 的公鑰就可以驗證它。

取得蘋果的公鑰

這裡蘋果有提供 API 來取得公鑰,

蘋果 API 文件在此:
https://developer.apple.com/documentation/sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature

用 GET 來打以下網址取得:

https://appleid.apple.com/auth/keys

它是一個標準 JSON Web Key Set (JWKS) 格式,
會得到類似這樣的資料

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "86D88Kf",
      "use": "sig",
      "alg": "RS256",
      "n": "iGaLqP..................zHLwQ",
      "e": "AQAB"
    }
  ]
}

礙於篇幅縮減了一下資料,重點說明:

  • kid:金鑰 ID,你可以找到剛剛範例的 86D88Kf 這個金鑰
  • alg:使用的演算法,範例值是 RS256
  • use:用途描述,白話文就是拿來幹嘛用的
    sig 意思就是拿來簽章用的
  • n:RSA 模數 (e),公鑰內容的一部分
  • e:RSA 指數 (n),公鑰內容的一部分

簡單來說,我們要:

  1. kid 找到對應的金鑰,
  2. ne 這二個值還原回 PEM 格式,
  3. 對 identityToken 做 Signature 驗證,檢核是否為蘋果伺服器發的
  4. 取用裡面的資料

以下使用 PHP 搭配 Lcobucci/JWTFirebase\JWT 套件來實作

require_once '../vendor/autoload.php';
function getUserDataFromIdentityToken($idToken)
{
    $token = (new Lcobucci\JWT\Parser())->parse((string)$idToken);

    $applePublicKeysRaw = curlGetAppleAuthKeys();
    $applePublicKeys = JWKParseKeySet($applePublicKeysRaw);

    $applePublicKey = $applePublicKeys[$token->getHeader('kid')];

    $signer = new Lcobucci\JWT\Signer\Rsa\Sha256();
    $keychain = new Lcobucci\JWT\Signer\Keychain();   
    if (!$token->verify($signer, $keychain->getPublicKey($applePublicKey))) {
        throw new RuntimeException("Key validation failed.");
    }

    if ('https://appleid.apple.com' !== $token->getClaim('iss')) {
        throw new RuntimeException("Source incorrect.");
    }
    $userData = array();
    $userData['email'] = $token->getClaim('email');
    $userData['id'] = $token->getClaim('sub');
    return $userData;
}

function curlGetAppleAuthKeys()
{
    $ch = curl_init('https://appleid.apple.com/auth/keys');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    $result = curl_exec($ch);
    curl_close($ch);

    return json_decode($result, true);
}

function JWKParseKeySet($keySets)
{
    $parsed = \Firebase\JWT\JWK::parseKeySet($keySets);
    $pemKeySets = array();
    foreach ($parsed as $keyId => $sslKey) {
        $pemKeySets[$keyId] = openssl_pkey_get_details($sslKey)['key'];
    }
    return $pemKeySets;
}

function JWKVerify($idToken, $publicKeyPem)
{
    $signer = new Lcobucci\JWT\Signer\Rsa\Sha256();
    $keychain = new Lcobucci\JWT\Signer\Keychain();
    $token = (new Lcobucci\JWT\Parser())->parse((string)$idToken);
    return $token->verify($signer, $keychain->getPublicKey($publicKeyPem));
}

composer.json

{
  "require": {
    "firebase/php-jwt": "5.2.0",
    "lcobucci/jwt": "3.3.2"
  }
}

使用 AuthorizationCode 交換 AccessToken

這部分也就是最困難的部分

先貼蘋果 API 文件:
https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

需要 POST 以下網址:

https://appleid.apple.com/auth/token

它定義了幾個參數

  • client_id
    如果是 App 登入就是 App ID (Bundle ID),
    如果是網站登入就是 Services ID。
  • client_secret
    一個 JWT 格式的要求文件,並利用前面申請的 Sign Key 來簽章
  • code
    填入 App 或 網站 收到的 Authorization Code
    (注意:Authorization Code 的效期非常短,文件上說最長只有 5 分鐘效期,
    但實際設定可能只有 30 秒至 1 分鐘左右)
  • grant_type
    這裡我們填 authorization_code 來交換 AccessToken

所以我們要:

  1. 自己組一個 JWT,並利用前面申請的 Sign Key 來簽章(指定使用 ES256 演算法),作為 client_secret,並帶入收到的 Authorization Code 作為參數,
  2. 打蘋果的 API

蘋果有給一個 JWT 的組成範例:

{
    "alg": "ES256",
    "kid": "ABC123DEFG"
}
  • alg:指定使用 ES256 (ECDSA-SHA256)
  • kid:你的 Sign Key 的 Key ID
{
    "iss": "DEF123GHIJ",
    "iat": 1437179036,
    "exp": 1493298100,
    "aud": "https://appleid.apple.com",
    "sub": "com.mytest.app"
}
  • iss:發行者(issuer)
    填入你的 開發者帳號的 TEAM ID
  • aud:接收者(audience)
    填入固定值 https://appleid.apple.com
  • iat:填入現在時間 (Unix timestamp)
  • exp:填入過期時間 (Unix timestamp),不能超過六個月
  • sub:Subject 主題的部分填入上述的 client_id
    如果是 App 登入就是 App ID (Bundle ID),
    如果是網站登入就是 Services ID。

最後簽章,組成一個 JWT 當作 client_secret 來打 API

成功的話會有類似以下的回傳值

{
  "access_token": "a6cab...........Y1A",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "r9d77fa9..........gfYA",
  "id_token": "eyJraW.................jMA"
}

其中一個重點, id_token 就是 identityToken,是我們要的資料,
解開後有:

  • sub :Subject 主題的值為 使用者 ID
  • email:值為使用者的 Email

這些前面有提到了,就不在重述。

Troubleshooting

雖然文件定義了很多錯誤訊息
https://developer.apple.com/documentation/sign_in_with_apple/errorresponse

大致會出現的只有幾種:

  • invalid_request 通常是連參數都弄錯
  • invalid_client 可能是 client_id 弄錯,
    client_secret (JWT 格式) 裡的 subclient_id 參數不吻合,或者根本沒這個 client_id
  • invalid_grant 這個是最常見(也是最難排查的)的錯誤,
    • 可能是 client_secret JWT 格式的加解密算錯 ⚠️
    • 可能是 Authorization Code 過期(沒即時打 API 做交換)
      (Authorization Code 效期超短,通常只有 1-2 分鐘,不超過 5 分鐘) ⚠️
    • 可能是 Authorization Code 已經被交換掉,變成無效 Code ⚠️
      (API 帶相同參數重複打就會出現)

Server to Server Notification

這個功能在新的 WWDC 2020 影片中推出,
截稿至今,蘋果還沒有寫 API 文件,只有釋出影片解釋 😓。

詳述在這裡

設定上去之後,蘋果會在用戶有帳號變更的時候通知你。

蘋果會用 POST 傳一個 json 格式給你,格式大致如下:

{
  "payload": "eyJraWQxxxxxxxxxxxxxxiUlMyNTYifQ.eyJpcxxxxxxxxxxxxxc0fSJ9.IUFWxPxxxxxxxxxxxxxxxxxbL3olA"
}

只有一個值叫做 payload,裡面也是一個 JWT 格式
你把 JWT 其中的 payload 用 base64UrlDecode 解開,會得到以下格式

{
  "iss": "https://appleid.apple.com/",
  "aud": "<Bundle Identifier>",
  "iat": 1508184845,
  "jti": "<unique events stream id>",
  "events": [
    {
      "type": "email-disabled",
      "sub": "<user_id>",
      "email": "<[email protected]>",
      "is_private_email": true,
      "event_time": 1508184845
    }
  ]
}

重點說明如下:

  • iss:發行者(issuer)
    https://appleid.apple.com,蘋果的伺服器
  • aud:接收者(audience)
    com.your.app.id (您的 App Id)
  • iat
    Issued at (time) 的簡稱,即該 JWT 發行的時間,用 Unix timestamp 表示。
  • jti:事件唯一碼
  • events:事件
    • type:目前狀態
    • sub:用戶唯一碼
    • email:用戶電子信箱(有可能是虛擬 Email)
    • is_private_email:是否為虛擬 Email
    • event_time:事件時間

其中 type 狀態有幾種:

  • email-enabled
  • email-disabled
  • consent-revoked
  • account-delete

祝串接順利。 🙂


參考資料