[教學] 在 QNAP NAS 機器上設定 WireGuard VPN

繼上一篇 [Linux 架站] Wireguard VPN 設定教學 手工建置的方式,有嘗試在 QNAP NAS 上面建立 WireGuard VPN。

但發現雖然 QNAP 有 Container station (其實裡面也是跑 Docker) 但沒辦法使用該 Docker Image
因為裡面有一個 net.ipv4.conf.all.src_valid_mark=1 選項它核心沒辦法支援。

不過呢,在 QVPN Service 3 直接原生支援 WireGuard 哦!
(QVPN Service 3 在 x86 機種上 QTS 5.0.0 以後版本支援)

這裡有繁體中文的官方教學文件,看起來圖文並茂,但還是有一些遺漏的地方,
您還是要需要知道純手工 Wireguard 怎麼設定才行。

以下是一些操作步驟,可以跟著一步一步操作就可以建立了!

安裝步驟

簡單介紹一下設定步驟:

啟用 WireGuard VPN 伺服器

  • 開啟 QVPN Service。
  • 前往[VPN 伺服器]>[WireGuard]。
  • 按一下[啟用 WireGuard VPN 伺服器]。

填寫

  • ServerName 伺服器名稱
  • PrivateKey 按一下 [Generate Keypairs] 按鈕 產生伺服器金鑰一對
  • PublicKey 會自動產生出可供複製
  • IP address 伺服器的內部 IP
  • Listen port 聆聽的連接埠 (假設為: 51820 ,可依需求修改)
  • Network interface 接收的介面,預設 All 就可以
  • DNS Server 使用的 DNS 伺服器
  • Peer Table 用戶端列表

按下 Add Peer 按鈕

  • Peer name 用戶端名稱,名字可自己取
  • Public key 用戶端的公鑰,需自行產生,這留到後面再說
  • Preshared key 共用加密金鑰,目前沒有設定,留空即可
  • Endpoint 伺服器位置,伺服器的連接方式
  • Allowed IPs 允許的 IP 網段
  • Persistent keepalive 維持連線時間,保留預設值 10 秒 即可

快速整理一下,跟純手工建置的差異:

  • 不用自行產生伺服器金鑰了,按一下 [Generate Keypairs] 就可以產生
  • 不用自行整理用戶端列表,IP 也自動隨著流水號增加

其他還是要自己手工做的部分:

  • 需要 自行撰寫 用戶端的設定文件
  • 自行產生 用戶端的金鑰,填回網頁介面
  • 如果有手機用戶的朋友,設定檔需要 自行產生 QRcode

個人覺得這 QNAP 只是做了半套而已,
如果有用過 wg-easy 的話,幾乎都全自動幫您產生好了。

安裝 wireguard-tools 並找到 wg 指令

這個步驟只要在幫大家做設定檔的那台電腦操作就可以了,
不需要每台使用 wireguard 的電腦都裝。

以下步驟要先找到 wg 指令,以 Mac 為例,可以用 brew 來安裝

$ brew install wireguard-tools

如果沒有 wg 指令,又剛好有用 docker 的話,可以用這個 image 來借用一下

$ sudo docker run --rm -it antrea/wireguard-go:0.0.20210424 /bin/bash

在 container 裡面執行就可以了。

產生用戶端金鑰

$ wg genkey | tee client1_privateKey | wg pubkey > client1_publicKey

就會產生二個檔案:

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

公鑰回填到 QNAP 的介面,存好私鑰,等一下來手動撰寫設定文件。

撰寫用戶端設定文件

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

  1. 用戶01私鑰 Client01 PrivateKey,帶入剛剛產生的私鑰
  2. 伺服器公鑰 Server PublicKey,這個從 QNAP 介面得到
  3. 配發用戶01的 IP (假設為:198.18.7.2/32,這邊要與 QNAP 介面一致)
  4. 伺服器位址 (假設為:vpn.example.com:51820,也就是你的 QNAP NAS 位址,是公開 IP 或者公開 DNS,這個等等補充)
  5. 共享密鑰 (如果有的話,本範例沒有)
  6. AllowedIPs 這部分為需要 VPN 轉導的區域,要填入一個 CIDR 的網段。
    換言之,需要定義一個區域,如果看到該區域,就走 VPN 連線。範例是給 192.168.1.0/24,意思是電腦若看到該網段則走 VPN,其餘走一般連線。
    如果需要全部都轉導的話填入:0.0.0.0/0
[Interface]
PrivateKey = AOAHHE........(用戶01私鑰 Client01 PrivateKey)........7RDE0=
Address = 198.18.7.2/32
DNS = 1.1.1.1

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

產生設定檔 QRcode

其實很簡單,就是把上述的設定檔,直接放入 QRcode 產生器就好了~

https://www.the-qrcode-generator.com/

類型選擇 Free text 你就會得到一個超密的 QRcode,你的手機就能掃了。

參考資料

Docker 設定避開衝突網段 ( 172.17.0.0/16 )

Docker 的好,用過的人就知道,
然而 Docker 在特定的網段裡面(例如: 172.17.x.x),會造成衝突。

嚴重造成 host 整台主機連不進來,無法連線。

這個時候要修改你的 Docker 預設網段,讓他跳過跟外界相符合的網段。

註:如果 你的開發機 (client) 與 你的伺服器 (server),兩個端點都有裝 Docker,
那兩個端點都要調整,跳開其網段。

註:預設 Docker 使用 172.17.0.0/16 網段。

修改 Docker 預設網段

Linux 系統的話,建立(或修改)daemon.json 這個檔案
(這個檔案預設是沒建立的,請自行建立。)

$ sudo vi /etc/docker/daemon.json

以下是範例格式,為 JSON 格式:

{
  "log-driver": "journald",
  "log-opts": {
    "tag": "{{.Name}}"
  },
  "default-address-pools": [
    {
      "base": "172.6.0.0/16",
      "size": 24
    }
  ],
  "bip": "172.7.0.1/16"
}

修改 bipdefault-address-pools 的欄位,輸入一個新的不衝突的網段即可。
(範例是改成 172.7.0.1/16172.6.0.0/16

  • bip 欄位是 docker 預設會開啟的網段。
  • default-address-pools 欄位是 docker-compose 如果有設定 network 區段的話,預設會配給的網段區域。

然後重新啟動 Docker 服務

$ sudo systemctl restart docker

這樣你的主機就會連線正常了。

自訂你自己服務的網段

在某些用途下你會需要一個特定幾個 container 能相互溝通的網路環境
你需要修改你的 docker network create 指令

例如:

$ sudo docker network create mynetwork --subnet=172.6.0.0/16

加上 --subnet= 參數,手動指定網段給他。

使用 docker-compose ?

如果是使用 docker-compose 的話,你可以這樣調整 networks 段落,

docker-compose.yml

version: '3.5'

services:
  web:
    image: crccheck/hello-world
    ports:
      - '80:8000'
    networks:
      - mynetwork
networks:
  mynetwork:
    ipam:
      config:
        - subnet: '172.6.0.0/16'

如果你要寫完整一點,這樣也是可以的。

networks:
  mynetwork:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: '172.6.0.0/16'
          gateway: '172.6.0.1'

ipamconfig 段落設定 subnetgateway 即可。

爾或者,修改 /etc/docker/daemon.json 中的 default-address-pools 的欄位,前面已經說過就不贅述了。

怎麼驗證?

你可以用 netstat 指令查詢

$ netstat -rn
Kernel IP routing table
Destination     Gateway         Genmask         Flags   MSS Window  irtt Iface
0.0.0.0         192.168.1.1     0.0.0.0         UG        0 0          0 eth0
172.6.0.0       0.0.0.0         255.255.0.0     U         0 0          0 br-c3ff076483d3
172.7.0.0       0.0.0.0         255.255.0.0     U         0 0          0 docker0

docker network ls 指令

$ docker network ls
NETWORK ID     NAME          DRIVER    SCOPE
da8e2609c93e   bridge        bridge    local
0eef0fedfb81   host          host      local
79cd2edd8448   none          null      local
c3ff076483d3   mynetwork     bridge    local

其中你可以看到範例 c3ff076483d3 對應 mynetwork 網段為 172.6.0.0

參考資料

Google 介面語言切換的小技巧

像我個人常使用英文介面,理當所有網頁都會給英文的結果,
但有時候會遇到一種情況,例如查看 Google Map 地圖,
地圖都是中文路名,翻譯成英文都沒辦法一眼看懂,要試著唸一下才知道再講什麼。
但 Google 家的產品,語言切換都藏的比較深,
這時候網址切換就比較方便。

以下教學只適用於 Google 家的產品,不同公司定義不一樣。
而 Google 隨時會改,
如果已經失效了的話再麻煩跟我說。

在 https:// …… /xxx?aaa=bbb&ccc=ddd

後面加上 &hl=zh-Hant 可切繁體中文
後面加上 &hl=en 可切英文

例如:

https://www.google.com/maps?hl=zh-Hant
就切成中文

https://www.google.com/maps?hl=en
就切成英文

需注意 URL Query string 的格式,
? (問號)開頭,中間用 & 符號分隔,
如果最開頭有了 ? (問號),那後面只能接 & 符號。

Proxmox VE (PVE) 伺服器虛擬化環境 安裝筆記

最近接觸到 Proxmox VE (PVE) 覺得超驚人的,把相關安裝知識整理後分享給大家。
Proxmox VE 是基於 Debian 修改的開源 (Open source) 虛擬化管理套件(基於 QEMU/KVM 與 LXC 技術)。
講到虛擬化 (virtualization),一定會提到 VMware 這家公司,它的 VMware ESXi 也是一絕,但 vSphere 系列需要授權,雖然有免費版本有對應的功能限制,下次會另開一篇來介紹。

本篇就來講講要怎麼開始、怎麼入門吧!

製作安裝 Live USB (Mac & Windows 使用者)

官方文件有特別強調,製作安裝 USB 時,絕不能使用 UNetbootin 這個工具
會無法正常開機,小弟本身也踩過這個雷。
(因為 UNetbootin 有另外處理開機引導 (bootloader) 的部分,這部分與 Proxmox VE 不相容。)

Mac & Windows 使用者 推薦用 Etcher

https://etcher.io

照個指示選擇 ISO 檔案,然後插入隨身碟選擇對應的隨身碟即可,這部分就不多述。

製作安裝 Live USB (Linux 使用者)

Linux 使用者 可以直接用 dd 指令

先列出有什麼 usb 裝置

# lsblk

然後使用 dd 指令直接複製

# dd bs=1M conv=fdatasync if=./proxmox-ve_*.iso of=/dev/XYZ

注意!這指令按下去會直接執行,請確定複製的目標路徑是否正確,不然弄錯會悲劇。

開啟虛擬化技術 (Intel VT-x / AMD-V)

另外一點,如果你要把 Proxmox VE 裝在實體機上的話,它需要在 BIOS 開啟虛擬化技術,
沒有開啟的話,它什麼事都做不了。
虛擬化技術在 Intel 跟 AMD 陣營有不同的名字:

  • Intel (Intel Virtualization Technology, Intel VT-x)
  • AMD (AMD Secure Virtual Machine, AMD SVM)

字詞雖不同,不過講的都是同一個功能。

這部分要在 BIOS 的 Chipset 裡面尋找對應的字詞(可能是 Virtualization Technology 之類的字詞)
然後開啟。這設定值有些預設為開啟,有些預設為關閉,如果安裝前有提示你的話,就要記得去開啟。
這部分每家廠牌主機板設定都不太一樣,沒有一體適用的教學。

安裝 Proxmox VE

建議是裝在實體機上,如果你要練習,裝在虛擬機上練習也是可以。

從 USB 隨身碟或光碟開機,然後使用 Next 大法,一路按 Next 安裝,應該不會遇到什麼問題。
注意硬碟會 重新格式化,不會保留資料。其中會設定 root 密碼與抓取當下的網路 IP,這部分要先記錄下來,安裝完成後重開機後會提示。安裝流程基本上不會遇到什麼問題。

比較有問題的應該是需在 BIOS 選擇正確的開機裝置,選擇到 USB 隨身碟。

移除網頁介面 No Valid Subscription 的提示 (選擇性)

這部分選擇性,做不做都可以。
每次登入網頁介面時都會出現 No Valid Subscription (無有效訂閱) 的提示,

You do not have a valid subscription for this server. Please visit www.proxmox.com to get a list of available options.

意思就是,你沒有一個有效訂閱,沒有訂閱其實也可以正常使用沒有關係,
但常期使用看到這個視窗會有一點點煩,以下就是教你怎麼移除它。

(以下方式存在版本上的差異,我使用的是 PVE 7.1,如果出了更新的版本,請參考套用或者不套用。)
(如果對程式語言不熟悉也建議跳過這段)

修改前請先備份程式碼。

# cp /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.bak

找到對應的檔案做修改:

# vi /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js

? 來搜尋 checked_command 關鍵字,nShift + N 來查找位置。

找到這個段落(修改前的版本):

checked_command: function(orig_cmd) {
    Proxmox.Utils.API2Request(
        {
            url: '/nodes/localhost/subscription',
            method: 'GET',
            failure: function(response, opts) {
                Ext.Msg.alert(gettext('Error'), response.htmlStatus);
            },
            success: function(response, opts) {
                let res = response.result;
                if (res === null || res === undefined || !res || res
                    .data.status.toLowerCase() !== 'active') {
                    Ext.Msg.show({
                        title: gettext('No valid subscription'),
                        icon: Ext.Msg.WARNING,
                        message: Proxmox.Utils.getNoSubKeyHtml(res.data.url),
                        buttons: Ext.Msg.OK,
                        callback: function(btn) {
                            if (btn !== 'ok') {
                                return;
                            }
                            orig_cmd();
                        },
                    });
                } else {
                    orig_cmd();
                }
            },
        },
    );
},

這個 if 條件式 ,裡面有緊接一個含有 No valid subscriptionExt.Msg.show() 函式。

Ext.Msg.show({
    title: gettext('No valid subscription'),
    icon: Ext.Msg.WARNING,
    message: Proxmox.Utils.getNoSubKeyHtml(res.data.url),
    buttons: Ext.Msg.OK,
    callback: function(btn) {
        if (btn !== 'ok') {
            return;
        }
        orig_cmd();
    },
});

我們直接槓掉整段打 API 驗證有沒有授權的程式,讓它跑原本的 orig_cmd(); 就好。

修改後的版本如下:

checked_command: function(orig_cmd) {
    orig_cmd();
},

然後重啟 Proxmox VE 網頁服務

# systemctl restart pveproxy

網頁介面會斷線 3-5 分鐘不等,稍等之後再重新連線。

安裝相關套件

以下是小弟我個人常用的一些套件,一行指令來安裝,你也可以紀錄你自己喜愛的套件,修改這行指令
(以下都預設用 root 帳號登入執行)

# apt install -y net-tools vim curl sudo

net-tools 裡面有 ifconfig 指令,習慣 ifconfig 指令可以選擇性安裝它。

安裝 sudo 套件與使用者設定

我使用的版本是 PVE 7.1,
目前 PVE 是沒有安裝 sudo 套件的。

如果有需要,一樣可以安裝。

# apt install -y sudo

這樣就跟一般 Linux 幾乎無異了。

編輯 sudo 設定檔

在編輯設定檔之前,小弟我會先修改 visudo 預設會使用的編輯器。
(因為小弟我實在不習慣預設的 nano 編輯器)

# sudo update-alternatives --config editor

會出現以下選單可以選擇:

There are 3 choices for the alternative editor (providing /usr/bin/editor).

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

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

個人喜好用 vi 所以選擇 vim.basic
如果沒有 vim.basic 選項,請安裝 vim

# apt install -y vim

編輯 sudo 設定檔,套用免密碼設定

編輯 sudo 設定檔,修改 sudo 時不需再次確認密碼
使用 visudo 修改指令

# visudo

在這行

%sudo   ALL=(ALL:ALL) ALL

把它改成

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

新增使用者&加入使用者

簡單介紹一下 sudo 的用法

可以用指令新增使用者(例如:新增名字叫做 newuser

# adduser newuser

(註:請先不要使用 useradd 指令,因為它不會幫忙建立家目錄)

加入該使用者進 sudo 的使用者群組(允許使用 sudo)

# usermod -aG sudo newuser

如果要修改這位使用者的密碼,可以用 passwd 指令

# passwd newuser

(註:後面 必須 要接正確的使用者的名稱,如果沒有該參數會修改到 目前使用者 的密碼,
假設你登入 root 帳號就像修改到 root 的密碼,需注意。)

安裝桌面環境 (選擇性)

這步驟不一定需要做。因為通常我們使用 PVE 都是從網頁管理介面直接來,
實體機只會有一個醜醜的黑白 Console 命令列。
如果你需要桌面環境,需要 GUI 的話,可以用以下步驟安裝桌面環境。

安裝 X windows 與桌面

先更新系統套件

# apt update -y && apt dist-upgrade -y

然後安裝指令安裝 xfce4 桌面環境, chromium 瀏覽器(開源版的 Google Chrome 瀏覽器)

# apt install -y xfce4 chromium lightdm

新增使用者

在啟動桌面之前,先新增一個普通 linux 使用者,避免使用 root 帳號來登入。
可以用指令新增使用者(例如:新增名字叫做 newuser

# adduser newuser

(註:請先不要使用 useradd 指令,因為它不會幫忙建立家目錄)

啟動 X windows

最後用指令啟動登入管理器:

# systemctl start lightdm

就有一個基本的桌面環境了。

改登入畫面桌布 (選擇性)

方式有二種,你可以安裝 lightdm-gtk-greeter-settings 圖形介面,

# apt install -y lightdm-gtk-greeter-settings

或者用以下方法改設定:

把桌布圖檔放到 /usr/share/pixmaps/ 底下(假設檔名為 wallpaper.jpg

然後修改 lightdm-gtk-greeter.conf 設定檔

# vi /etc/lightdm/lightdm-gtk-greeter.conf

[greeter] 這個段落加上一行

background=/usr/share/pixmaps/wallpaper.jpg

成果如下:

[greeter]
background=/usr/share/pixmaps/wallpaper.jpg

:wq 存檔離開。

最後,重啟 lightdm 服務

# systemctl restart lightdm

就會看到新的桌布了~

基本 Proxmox VE 使用方式

以下介紹 Proxmox VE 基本使用方式,建立虛擬機 (Virtual machine, VM) 跟 Container 容器。

建立虛擬機 (Virtual machine, VM)

網頁介面進去,右上角就有二個大大的按鈕,
一個是 Create VM ,一個是 Create CT

首先,先去下載你要的系統光碟檔 (ISO) 用網頁上傳上去。

然後我們按下 Create VM,選擇內核,選擇 ISO 檔案,
設定 CPU、記憶體、硬碟容量大小,最後按 Finish 完成。
中間應該不會有什麼問題。

建立好的 VM 之後,選擇按下 Start 就開機了,
之後就是作業系統的安裝與操作,就這麼簡單。

建立 Container 容器

先說,這個 Container 不是 docker 裡講的那個 Container,而是 LXC 的 Container,
概念雖接近,但不是相同的東西。

簡單來說,虛擬機 (Virtual machine, VM) 會是模擬一個「完整的」電腦系統,
虛擬 BIOS、虛擬處理器、記憶體、硬碟、…等等。

Container 是一個從作業系統層,利用一些容器隔離技術,直接跑起來的 Process (處理程序)。
為了減少中間虛擬化造成的效能損耗。
如果有玩過,你會發現 Container 它會比 VM 較快。
但也有一些限制,就不是什麼 Image 都有,部分特殊情況會有相容性問題。

如果你有遇到問題,可以做一份相同版本的 VM 跟 Container,二者交叉比較看看。
CT Template 的區域有一些現成的 Templete 可以下載使用,常見的 Linux 版本都有。

像我個人使用 Ubuntu ,使用 Ubuntu 20.04 版本。
像是學 Redhat 系列的可以選 CentOS、 Rocky Linux 或 Fedora Core、
想要系統比較小可以選 Alpine…純憑個人喜好。

跟 VM 的建立方式接近,但你會發現預設資源設定就少很多,而且比較順暢,可以推薦玩玩看。

幾個重要的檔案路徑

列出幾個重要的檔案路徑,到時候要複製檔案的時候才知道要去哪裡找。

ISO 檔案的路徑

ISO 檔案的路徑在:
/var/lib/vz/template/iso/

這個是網頁介面上傳 ISO 檔案會存放的路徑。

虛擬機備份路徑

你的虛擬機 (VM) 備份、容器 (Container) 備份,
檔案位置都在 /var/lib/vz/dump/ 底下。

設定檔位置

開啟若干個虛擬機 (VM) 或 容器 (Container) 後,設定檔在
/etc/pve/lxc/ 底下。

例如:
我要修改 VMID = 100 的虛擬機,設定檔就是:
/etc/pve/lxc/100.conf

以此類推。

以上粗淺的分享,還有很多地方沒有提到,之後文章會慢慢補充。

參考資料

[教學] 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"
然後綁定前述的值,回應一個預設訊息。


參考資料