這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
一旦接入驗證成功,成為正式開發人員,你可能會迫不及待地想通過手機發送一條"Hello, Wechat”到你的公眾號伺服器。不過上一篇的那個程式還無法處理手機提交的簡訊,本篇將介紹如何用Golang編寫公眾號程式來接收手機端發送的 簡訊以及回複響應訊息。
根據公眾平台開發文檔中描述:“當普通使用者向公眾帳號發訊息時,伺服器將POST訊息的XML資料包到開發人員填寫的URL上”。我們 用一個展示一下這個訊息流程程:
伺服器通過一個HTTP Post請求將終端使用者發送的訊息轉寄給公眾號伺服器,訊息內容被封裝在HTTP Post Request的Body中。資料包以XML格式儲存,文本類訊息XML格式範例如下(引自公眾平台開發文檔):
資料包中各個欄位的含義都顯而易見,我們重點關注的時Content這個欄位填寫的內容,也就是終端使用者發送的訊息內容。為了得到這個欄位值,我 們需要解析伺服器發來的HTTP Post包的Body。
在“接入驗證”一文中我們提到過,伺服器發起的請求都帶有驗證欄位,可被公眾號服務用於驗證HTTP Request是否來自於伺服器,避免惡意請求。這些用於驗證來源的資訊,不僅僅在接入驗證階段會發給公眾號伺服器,在後續伺服器與公眾號伺服器 的訊息互動過程中,HTTP Request中也都會攜帶這些資訊(注意:沒有echostr參數了)。
下面我們來看接收簡訊的Golang程式。
一、接收簡訊
公眾號所用的HTTP Server可以沿用“接入驗證”一文中的那個main中的Server,我們需要修改的是procRequest函數。
在procRequest函數中,我們保留validateUrl,用於校正請求是否來自於伺服器。
func procRequest(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
if !validateUrl(w, r) {
log.Println("Wechat Service: this http request is not from Wechat platform!")
return
}
log.Println("Wechat Service: validateUrl Ok!")
… …//在此解析HTTP Request Body
}
通過驗證後,我們開始解析HTTP Request的Body,Body中的資料是XML格式的,我們可以通過Golang標準庫encoding/xml包中提供的函數對Body進行解 析。encoding/xml根據xml欄位名與struct欄位名或struct tag(struct中每個欄位後面反單引號引用的內容,比如xml: "xml")的對應關係將xml資料中的欄位值解析到struct的欄位中,因此我們需要根據這個xml包的組成定義出對應該格式的struct,這個 struct定義如下:
type TextRequestBody struct {
XMLName xml.Name `xml:"xml"`
ToUserName string
FromUserName string
CreateTime time.Duration
MsgType string
Content string
MsgId int
}
其中FromUserName是發送方帳號,這是一個OpenID,每個使用者針對某個關注的公眾號都有唯一OpenID。舉個例 子:"tonybai"這個使用者,關注了"GoNuts"和"GoDev"兩個公眾號,則"tonybai"發給GoNuts的訊息中的 OpenID是“tonybai-gonuts”,而tonybai發給GoDev的訊息中的OpenID則是“tonybai-godev”。
MsgId是一個64位整型,可用於訊息排重。對於一個HTTP Post,伺服器在五秒內如果收不到響應會斷掉串連,並且針對該訊息重新發起請求,總共重試三次。嚴謹的公眾號服務端實現是應該實現訊息排重功能的。
通過encoding/xml包中的Unmarshal函數,我們將上面的xml資料轉換為一個TextRequestBody執行個體,具體代碼如 下:
//recvtextmsg_unencrypt.go
func parseTextRequestBody(r *http.Request) *TextRequestBody {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
return nil
}
fmt.Println(string(body))
requestBody := &TextRequestBody{}
xml.Unmarshal(body, requestBody)
return requestBody
}
func procRequest(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
if !validateUrl(w, r) {
log.Println("Wechat Service: this http request is not from Wechat platform!")
return
}
if r.Method == "POST" {
textRequestBody := parseTextRequestBody(r)
if textRequestBody != nil {
fmt.Printf("Wechat Service: Recv text msg [%s] from user [%s]!",
textRequestBody.Content,
textRequestBody.FromUserName)
}
}
}
構建並執行該程式:
$>sudo ./recvtextmsg_unencrypt
2014/12/19 08:03:27 Wechat Service: Start!
通過手機或公眾開發平台提供的頁面調試工具發送"Hello, Wechat",我們可以看到如下輸出:
2014/12/19 08:05:51 Wechat Service: validateUrl Ok!
Wechat Service: Recv text msg [Hello, Wechat] from user [oBQcwuAbKpiSAbbvd_DEZg7q27QI]!
上述接收"Hello, Wechat"簡訊的Http抓包分析文本如下(Copy from wireshark output):
POST /?signature=9b8233c4ef635eaf5b9545dc196da6661ee039b0×tamp=1418976343&nonce=1368270896 HTTP/1.0\r\n
User-Agent: Mozilla/4.0\r\n
Accept: */*\r\n
Host: wechat.tonybai.com\r\n
Pragma: no-cache\r\n
Content-Length: 286\r\n
Content-Type: text/xml\r\n
公眾號伺服器給伺服器返回的HTTP Post Response為:
HTTP/1.0 200 OK\r\n
Date: Fri, 19 Dec 2014 08:05:51 GMT\r\n
Content-Length: 0\r\n
Content-Type: text/plain; charset=utf-8\r\n
二、響應簡訊
上面的例子中,終端使用者發送"Hello, Wechat",雖然公眾號伺服器成功接收到了這段內容,但終端使用者並沒有得到響應,這顯然不那麼友好!這裡我們來給終端使用者補發一個簡訊的響 應:Hello,使用者OpenID。
這類響應訊息可以通過HTTP Post Request的Response包攜帶,將資料放入Response包的Body中,當然也可以單獨向公眾平台發起請求(後話)。公眾平台開發 文檔中關於被動的簡訊響應的定義如下:
這與前面的接收訊息結構極其類似,欄位含義也不說自明。Golang encoding/xml中的Marshal(和MarshalIndent)函數提供了將struct編碼為XML資料流的功能,它是 Unmarshal的逆過程,Golang實現回複 文本響應訊息的代碼如下:
type TextResponseBody struct {
XMLName xml.Name `xml:"xml"`
ToUserName string
FromUserName string
CreateTime time.Duration
MsgType string
Content string
}
func makeTextResponseBody(fromUserName, toUserName, content string) ([]byte, error) {
textResponseBody := &TextResponseBody{}
textResponseBody.FromUserName = fromUserName
textResponseBody.ToUserName = toUserName
textResponseBody.MsgType = "text"
textResponseBody.Content = content
textResponseBody.CreateTime = time.Duration(time.Now().Unix())
return xml.MarshalIndent(textResponseBody, " ", " ")
}
func procRequest(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
if !validateUrl(w, r) {
log.Println("Wechat Service: this http request is not from Wechat platform!")
return
}
if r.Method == "POST" {
textRequestBody := parseTextRequestBody(r)
if textRequestBody != nil {
fmt.Printf("Wechat Service: Recv text msg [%s] from user [%s]!",
textRequestBody.Content,
textRequestBody.FromUserName)
responseTextBody, err := makeTextResponseBody(textRequestBody.ToUserName,
textRequestBody.FromUserName,
"Hello, "+textRequestBody.FromUserName)
if err != nil {
log.Println("Wechat Service: makeTextResponseBody error: ", err)
return
}
fmt.Fprintf(w, string(responseTextBody))
}
}
}
編譯執行上面程式後,通過手機或網頁調試工具發送一條"Hello, Wechat"到公眾號,公眾號會響應如下資訊:“Hello, oBQcwuAbKpiSAbbvd_DEZg7q27QI",手機端會正確接收該響應。
上述響應的抓包分析如下。公眾號伺服器給伺服器返回的HTTP Post Response為:
HTTP/1.0 200 OK\r\n
Date: Fri, 19 Dec 2014 09:03:55 GMT\r\n
Content-Length: 220\r\n
Content-Type: text/plain; charset=utf-8\r\n
\r\n
oBQcwuAbKpiSAbbvd_DEZg7q27QIgh_xxxxxxxx1418979835textHello, oBQcwuAbKpiSAbbvd_DEZg7q27QI
三、關於Content-Type設定
雖然Content-Type為:text/plain; charset=utf-8的 響應資訊可以被平台正確解析,但通過抓取平台給公眾號伺服器發送的HTTP Post Request來看,在發送xml資料時伺服器用的Content-Type為Content-Type: text/xml。我們的響應資訊Body也是xml資料包,我們能否為響應資訊重新設定Content-Type為 text/xml呢?我們可以通過如下代碼設定:
w.Header().Set("Content-Type", "text/xml")
fmt.Fprintf(w, string(responseTextBody))
不過奇怪的是我通過AWS EC2上抓包得到的Content-Type始終是“text/plain; charset=utf-8”。但利用ngrok映射到本地連接埠後抓包看到的卻是正確的"text/xml",在AWS本地用 curl -d xxx.xxx.xxx.xxx測試公眾號服務程式而抓到的包也是正確的。通過代碼沒看出什麼端倪,因為邏輯上顯式設定Header的Content- Type後,Go標準庫不會在sniff內容的格式了。
通過ngrok映射本地80連接埠後,得到的HTTP Post Response抓包分析文字:
HTTP/1.1 200 OK\r\n
Content-Type: text/xml\r\n
Date: Sat, 20 Dec 2014 04:29:16 GMT\r\n
Content-Length: 220\r\n
xml資料包這裡忽略。
四、CDATA的使用
從抓包可以看到,我們回複的響應中的XML資料包是不帶CDATA,即便這樣用戶端接收也沒有問題。但這並未嚴遵循協議範例。
XML下CDATA含義是:在標記CDATA下,所有的標記、實體引用都被忽略,而被XML處理常式一視同仁地當做字元資料看待,CDATA的形 式如下:
常值內容
我們嘗試加上為每個文本類型的欄位值上直接添加CDATA標記。
func value2CDATA(v string) string {
return "" + v + ""
}
func makeTextResponseBody(fromUserName, toUserName, content string) ([]byte, error) {
textResponseBody := &TextResponseBody{}
textResponseBody.FromUserName = value2CDATA(fromUserName)
textResponseBody.ToUserName = value2CDATA(toUserName)
textResponseBody.MsgType = value2CDATA("text")
textResponseBody.Content = value2CDATA(content)
textResponseBody.CreateTime = time.Duration(time.Now().Unix())
return xml.MarshalIndent(textResponseBody, " ", " ")
}
這樣修改後,我們試著發一條訊息給公眾號平台,不過結果並不正確。手機無法收到響應資訊,並顯示“該公眾號暫時無法提供服務,請稍後再 試”。通過Println輸出Body可以看到:
<![CDATA[oBQcwuAbKpiSAbbvd_DEZg7q27QI]]><![CDATA[gh_1fd4719f81fe]]>1419051400<![CDATA[text]]><![CDATA[Hello, oBQcwuAbKpiSAbbvd_DEZg7q27QI]]>
可以看到左右角括弧分別被轉義為<和>了,這顯然不是我們想要的結果。那如何加入CDATA標記呢。Golang並 不直接顯式支援產生CDATA欄位的xml流,我們只能間接實現。前面提到過struct定義時的struct tag,golang xml包規定:"a field with tag ",innerxml" is written verbatim, not subject to the usual marshalling procedure"。 大致的意思是如果一個欄位的struct tag是",innerxml",則Marshal時欄位值原封不動,不提交給通常的marshalling程式。我們就利用innerxml來實現 CDATA標記。
type TextResponseBody struct {
XMLName xml.Name `xml:"xml"`
ToUserName CDATAText
FromUserName CDATAText
CreateTime time.Duration
MsgType CDATAText
Content CDATAText
}
type CDATAText struct {
Text string `xml:",innerxml"`
}
func value2CDATA(v string) CDATAText {
return CDATAText{"" + v + ""}
}
編譯器後測試,這回CDATA標記正確了,用戶端也收到的響應資訊。
五、用ngrok在本地調試公眾平台介面
在“接入驗證”一文中,我們建議申請諸如AWS EC2來應對公眾平台介面開發,但其方便程度畢竟不如本地。網上一開源工具ngrok可以協助我們實現本地調試公眾平台介面。
使用ngrok的步驟如下:
1、下載ngrok
ngrok也是使用golang實現的,因此主流平台都支援。ngrok下載後就是一個可執行檔二進位檔案,可直接執行(放在PATH路徑 下)。
2、註冊ngrok
到ngrok.com上註冊一個帳號,註冊成功後,就能看到ngrok.com為你分配的auth token,把這個auth token放到~/.ngrok中:
auth_token:YOUR_AUTH_TOKEN
3、執行ngrok
$ngrok 80
ngrok (Ctrl+C to quit)
Tunnel Status online
Version 1.7/1.6
Forwarding http://xxxxxxxx.ngrok.com -> 127.0.0.1:80
Forwarding https://xxxxxxxx.ngrok.com -> 127.0.0.1:80
Web Interface 127.0.0.1:4040
# Conn 1
Avg Conn Time 1.90ms
其中"xxxxxxxx.ngrok.com"就是ngrok為你分配的子網域名稱。
在你的開發人員中心將這個地址配置到URL欄位中,提交驗證,驗證訊息就會順著ngrok建立的隧道流到你的local機器的80連接埠上。
另外本地調試抓包,要用loopback網口,比如:
$sudo tcpdump -w http.cap -i lo0 tcp port 80
本篇文章涉及的代碼在這裡可以找到。
2014 – 2015, bigwhite. 著作權.