近期遇到的3个Golang代码问题

No Comments

这两周来业余时间都在用Golang写代码,现在处于这样一个状态:除了脚本,就是Golang了。反正能用golang实现的,都用golang写。

Golang语言相对成熟了,但真正写起来,还是要注意一些“坑”的,下面是这周遇到的三个问题,这里分享出来,希望能对遇到同样问题的童鞋有所帮助。

一、误用定时器,狂占CPU

golang中有一个通过channel实现timeout或tick timer的非常idiomatic的方法,代码如下:

func worker(start chan bool) {
        for {
                timeout := time.After(30 * time.Second)
                 select {
                         // … do some stuff
                         case <- timeout:
                                 return
                 }
        }
}

func worker(start chan bool) {
        for {
                heartbeat := time.Tick(30 * time.Second)
                 select {
                         // … do some stuff
                         case <- heartbeat:
                                 return
                 }
        }
}

没错,就像上面这两个例子,如果你单独执行它们,你不会发现任何问题,但是当你将这样的代码放到一个7 * 24小时的Service中,并且timeout间隔或heartbeat间隔为更短时间,比如1s时,问题就出现了。

我的程序最初就是用上面的代码实现了一个timewheel,通过放置在一个单独goroutine中的定时器检测timewheel是否有到期的 timer。程序跑在后台运行的很好,直到有一天晚上我无意中执行了一下top,我发现这个service居然站用了40%多的CPU负荷。最初我怀疑是 不是代码中有死循环,但仔细巡查一遍代码后,没有发现死循环的痕迹,算法逻辑也没问题。

于是重启了一下这个service,发现cpu占用降了下来。出去去了趟卫生间,回来继续用top观察,不好,这个service占用了1%的CPU,再 过一会升到2%,观察一段时间后,发现这个service对cpu的占用率随着时间的推移而增加。gdb attach了相应的进程号,stack多是go runtime的调度。

再次回到代码,发现可能存在问题的只有这里的tick。我的tick间隔是1s。这样每1s都会创建一个runtime timer,而通过runtime的源码来看,这些timer都扔给了runtime调度(一个heap)。时间长了,就会有超多的timer需要 runtime调度,不耗CPU才怪。

于是做了如下修改:

func worker(start chan bool) {
        heartbeat := time.Tick(1 * time.Second)
         for {
               
                 select {
                         // … do some stuff
                         case <- heartbeat:
                                 return
                 }
        }
}

重新编译执行service,观察了一天,cpu再也没有升高过。

二、小心list.List的Delete逻辑

其实这是一个在哪种语言中都会遇到的初级问题,这里只是给大家提个醒罢了。不多说了,上代码:

从一个list.List中删除一个element,一般逻辑是:

l := list.New()
… …
for e := l.Front(); e != nil; e = e.Next() {
        if e.Value.(int) == someValue {
                l.Remove(e)
                return or break
        }
}

但是如果list里有重复元素,且代码要遍历整个list删除某个值为somevalue的元素呢?上面的一般方法是由逻辑缺陷的,例子:

func foo(i int) {
        l := list.New()
        for i := 0; i < 9; i++ {
                l.PushBack(i)
        }
        l.PushBack(6)

        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }

        for e := l.Front(); e != nil; e = e.Next() {
                if e.Value.(int) == i {
                        l.Remove(e)
                }
        }

        fmt.Printf("\n")
        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }
        fmt.Printf("\n")
}

func main() {
        foo(6)
}

该程序试图删除list中的所有值为6的element,但执行结果却是:

go run testlist.go
0123456786
012345786

list中尾部的那个6没有被删除,程序似乎在删除完第一个6之后就不再继续循环了。事实也是这样:

当l.Remove(e)执行后,e.Next()被置为了nil,这样循环条件不再满足,循环终止。

为此,对于这样的程序,下面的方法才是正确的:

func main() {
        bar(6)
}

func bar(i int) {
        l := list.New()
        for i := 0; i < 9; i++ {
                l.PushBack(i)
        }
        l.PushBack(6)

        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }

        var next *list.Element
        for e := l.Front(); e != nil; {
                if e.Value.(int) == i {
                        next = e.Next()
                        l.Remove(e)
                        e = next
                } else {
                        e = e.Next()
                }
        }

        fmt.Printf("\n")
        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }
        fmt.Printf("\n")
}

执行结果:
$ go run testlist.go
0123456786
01234578

三、要给template起个正确的名字

编写一个Web程序,需要用到html/template。

… …
t := template.New("My Reporter")
t, err = t.ParseFiles("views/report.html")
if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
}

t.Execute(w, UserInfo{xx: XX})

结果一执行却crash了:

[martini] PANIC: runtime error: invalid memory address or nil pointer dereference
/usr/local/go/src/runtime/panic.go:387 (0×16418)
/usr/local/go/src/runtime/panic.go:42 (0x1573e)
/usr/local/go/src/runtime/sigpanic_unix.go:26 (0x1bb50)
/usr/local/go/src/html/template/template.go:59 (0x7ed64)
/usr/local/go/src/html/template/template.go:75 (0x7ef0d)
/Users/tony/Test/GoToolsProjects/src/git.oschina.net/bigwhite/web/app.go:104 (0x2db0)
    reportHandler: t.Execute(w, UserInfo{xx: XXX})

问题在t.Execute这行,单独把template代码摘出来放在一个测试代码中:

//testtmpl.go
type UserInfo struct {
        Name string
}

func main() {
        t := template.New("My Reporter")
        t, err := t.ParseFiles("views/report.html")
        if err != nil {
                fmt.Println("parse error")
                return
        }

        err = t.Execute(os.Stdout, UserInfo{Name: "tonybai"})
        if err != nil {
                fmt.Println("exec error", err)
        }
        return
}

执行结果:
go run testtmpl.go
exec error template: My Reporter: "My Reporter" is an incomplete or empty template; defined templates are: "report.html"

看起来似乎template对象与模板名字对不上导致的错误啊。修改一下:

t := template.New("report.html")

执行结果:

<html>
<head>
</head>
<body>
    Hello, tonybai
</body>
</html>

这回对了,看来template的名字在与ParseFiles一起使用时不是随意取的,务必要与模板文件名字相同。

ParseFiles支持解析多个文件,如果是传入多个文件该咋办?godoc说了,template名字与第一个文件名相同即可。

一个有关Golang变量作用域的坑

4 Comments

临近下班前编写和调试一段Golang代码,但运行结果始终与期望不符,怪异的很,下班前依旧无果。代码Demo如下:

//testpointer.go
package main

import (
        "fmt"
)

var p *int

func foo() (*int, error) {
        var i int = 5
        return &i, nil
}

func bar() {
        //use p
        fmt.Println(*p)
}

func main() {
        p, err := foo()
        if err != nil {
                fmt.Println(err)
                return
        }
        bar()
        fmt.Println(*p)
}

这段代码原意是定义一个包内全局变量p,用foo()的返回值对p进行初始化,在bar中使用p。预期结果:bar()和main()中均输出5。但编译执行后的结果却是:

$go run testpointer.go
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x0 pc=0x20d1]

goroutine 1 [running]:
main.bar()
    /Users/tony/Test/Go/testpointer.go:17 +0xd1
main.main()
    /Users/tony/Test/Go/testpointer.go:26 +0x11c

goroutine 2 [runnable]:
runtime.forcegchelper()
    /usr/local/go/src/runtime/proc.go:90
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:2232 +0×1

goroutine 3 [runnable]:
runtime.bgsweep()
    /usr/local/go/src/runtime/mgc0.go:82
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:2232 +0×1

goroutine 4 [runnable]:
runtime.runfinq()
    /usr/local/go/src/runtime/malloc.go:712
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:2232 +0×1
exit status 2

晚饭后,继续调试这段代码。怎么还crash了!代码看似半点问题都没有,难道是Go编译器的问题,我用的可是最新的1.4,切换回1.3.3,问题依旧啊。看来还是代码的问题,但问题在哪里呢?加上些打印语句再看看:

func bar() {
        //use p
        fmt.Printf("%p, %T\n", p, p) //output:
0x14dc80, 0×0, *int
        fmt.Println(*p) //Crash!!!
}

func main() {
        fmt.Printf("%p, %T\n", p, p) //output: 0x14dc80, 0×0, *int
        p, err := foo()
        if err != nil {
                fmt.Println(err)
                return
        }
        fmt.Printf("%p, %T\n", p, p) //output: 0x2081c6020, 0x20818a258, *int
        bar()
        fmt.Println(*p)
}

通过打印输出,发现从foo函数中返回的p(0x2081c6020)与全局变量的p(0x14dc80)居然不是一个地址,也就是说不是一个变量。而且 从bar()中的调试输出来看,全局变量p在foo函数返回时并未被赋值为foo中变量i的地址,而依然是一个nil值,从而导致程序Crash。

好了,废话不说了,该是揭晓真相的时候了。问题就在于":="。在main这个作用域中,我们使用了

p, err := foo()

最初的理解是golang会定义新变量err,p为初始定义的那个全局变量。但实际情况是,对于使用:=定义的变量,如果新变量p与那个同名已定义变量 (这里就是那个全局变量p)不在一个作用域中时,那么golang会新定义这个变量p,遮盖住全局变量p,这就是导致这个问题的真凶。

我们将main函数改为:

func main() {
        var err error
        p, err = foo()
        if err != nil {
                fmt.Println(err)
                return
        }
        bar()
}

则执行结果就完全符合预期了。

2014小结

No Comments

2014年的最后一个工作日,这里写下有关2014年的一份小结。

年终总结本无固定格式,但写了若干年后,便有了自己的格式。但今年不打算遵循这个格式了,跳出自己的舒适区,随意写写。

2014年12月底,随着亚航QZ8501航班的最后一掉,航空史上都为数不多的灾难年终于画上了句号,留给人们的是久久的惊恐不安,留给遇难者 家属们的是无法释怀的悲伤。2014年12月31日15点,随着A股上证指数最后一个交易日收涨68.86点,稳稳站上3200点,让广大股民们 看到了2015年牛市持续赚钱的希望。不知为何,这个世界几乎总是同时上演着冰与火两种剧本。

短信与微信(包括其他X信)的博弈亦是如此。

短信,这一红极一时的让移动运营商赚得盆满钵满的廉价沟通工具如今却早已成明日黄花。不妨打开手机,翻看一下你的手机通信录,短信列表中是不是除 了验证码(登录、支付业务),就是各种营销垃圾广告,或者是移动运营商自有的客服信息呢。我相信我的情况应该可以代表广大群众了。

随着微信今年推出“企业号”,微信几乎完成了对短信的业务合围:

点对点短信 vs. 联系人、朋友圈、群
SP短信    vs. 订阅号、服务号
行业短信  vs. 服务号、企业号 (营销、售后、内部OA、CRM等)

今年年初招商银行信用卡将300以内的消费提醒短信取消,改为微信提醒,其实就是一个看高微信,看空短信的行为。只是考虑到到达率(用户未开网络时),没 有将大额消费全部转到微信上,而是短信和微信都做提醒。一旦无线网络接入、资费门槛下降、网络速度提升、终端实时在线不再是问题,达到率也将 不是问题时,微信会对短信发起最后的总攻。

这么对比其实也不公平,因为短信和微信本不是一个重量级的对手。从出生的那天起,微信就被赋予了崇高的使命,非短信可比。微信试图连接一切,做统 一入口,建立庞大生态圈;而短信仅仅是一个通道工具罢了。

面对移动短信市场的衰败,移动运营商也在挣扎,也在试图翻盘,或至少平起平坐,但就我了解到的移动运营商产品开发与运营的风格,想和互联网巨头T 掰手腕,下场必输无疑。中国移动年初也蛮拼的,喊出了"RCS(融合通信)"与微信抢手机社交入口,但这都到了2014最后一天了,RCS依旧不见踪 影。

短信免费或退出历史舞台就像周鸿祎在其书《周鸿祎自述:我的互联网方法论》中说的那样是“趋势”,不可违!

我们就是为中国移动短信业务提供服务端软件和方案的。短信若是没了(或变成鸡肋),我们干啥?冰冷的现实摆在大家面前,领导跟我 们说:“转型”。

2014年,至少我们依旧在转型中。老板们把“转型”依旧约束在“移动运营商”这棵大树下面,这让我们转的不那么纯粹,有些拖泥带水,可持续盈利 的业务方向并不明显。从目前来看,今年收入依旧靠传统业务渠道获得。

虽说要“转型”,但领导今年给我的任务却是做好守门员,守住现有市场份额,保证产品线上无事。这并非如我所愿,在一个业务线耕耘多年,业务和技术 能力均到达了天花板,对我个人来说,这不是一个很好的发展规划。但考虑到下面的技术负责人、员工在技术和业务火候儿还欠缺那么一些,我答应了留 守,但会投入部分精力做个人技术转型储备。

业务的转型需要技术做支撑,局限于传统后台服务系统的我们需要张开怀抱,拥抱那些“流行”的新玩意儿。我首先试水!从2014年我的博客中你也许 可以看得出来我试水过的技术,我在尝试跳出自己的各种舒适区,向一些近两年兴起的、将来比较有前途的技术方向靠拢,学习移动互联网的思维和潮流。

上半年曾尝试过终端产品开发的技术,还为此购入若干数码装备,但试过后才发现这仍然不是我的主菜,就和10年前Windows GUI程序开发不是我的菜一样。但这个过程并非没有收获,未来任何业务不与终端开发打交道是不可能的,这个接触过程让我了解到了终端开发的重点和难点,于 是总结经验,整理教训。

正当准备调整方向、重新上路之际,家里出现重大变故,耗费了我整整1个多月的时间,一切几乎都停滞了,直到10月份我才渐渐重新进入状态。

在公司内部技术社区看到公司CTO的一篇文章,讲述移动互联网正在由消费者驱动向企业驱动转变(来自麦肯锡报告),结合微信推出企业号、用友软件的转型来 看(今天听说用友软件更名为“用友网络”了,决心向互联网转型),这个趋势也是我比较认同的,这个方向以及相关技术也是我在正在涉猎以及即将涉猎的。不过 关于企业互联网服务以及平台,自己的相关业务经验、技术和积累还是甚少,征途必然坎坷,自己还需“拼”一下!关于微信这个平台,这个入口,它是腾讯未来战 略的核心,靠着腾讯这棵大树,至少未来几年发展应该还是不错的。

公司的大BOSS这两年一直提倡“创业者的精神",学会在逆境中成长,在困境中成功。但作为在短信这个行业内浸淫了十多年的部门,我们不免产生一些惰性, 更愿意躺在现有的温床上“享受生活”,立足于现有的平台做舒服的事情。经历过2014年的严峻形势,现在的我们应该清醒的认识到这样的舒服生活,温床和平 台都可能将远离我们。如果我们再不主动站起来,我们将再无力站起了。

2014年在个人发展方面做出了“妥协”,2015我打算轻装前行,这对我、对团队成员的成长都是有好处的。年底给领导发总结时,已经和领导书面提出退出 当前业务线的想法。虽然目前还没有收到回复,不过无论怎样,我都坚定了决心,自己作为这个产品线的负责人,已经起不到领路的作用了,是时候退出了。

2015,给自己的关键字是“创业”。《精益创业》一书中作者似乎有这样一句话:“你不一定非要在车库里折腾才算是创业”,在企业内部也可以“创业”,为创造某种新产品或新服务为目的而组建的一个团队或组织内的人都是“创业者”。

以往年份的小结,我总会总结一些数据,比如blog文章、读过多少本书等等。但今年这些数据就不统计了,自己对自己的考核指标"KPI"有所调整,以前哪些指标已经不算数了,列出也就无意义了。

2014这一年,LP给了我很大压力!我能理解,她期望我能取得更大的成功。这让我“亚历山大”啊,这回可是真的。

要说新年的愿望是什么?希望2015年年末时能为自己2015年的所作所为,所取得的进步和成果点个赞

使用Golang开发微信公众平台-发送客服消息

No Comments

关注并使用过微信“飞常准”公众号的朋友们都有过如下体验:查询一个航班情况后,这个航班的checkin、登机、起降等信息都会在后续陆续异步发给你,这个服务就是通过微信公众平台的客服消息实现的。

微信公众平台开发文档中关于客服消息的解释如下:“当用户主动发消息给公众号的时候(包括发送信息、点击自定义菜单、订阅事件、扫描二维码事件、支付成功 事件、用户维权),微信将会把消息数据推送给开发者,开发者在一段时间内(目前修改为48小时)可以调用客服消息接口,通过POST一个JSON数据包来 发送消息给普通用户,在48小时内不限制发送次数。此接口主要用于客服等有人工消息处理环节的功能,方便开发者为用户提供更加优质的服务”。

这篇文章我们就来说说如何用golang实现发送文本客服消息。

一、获取access_token

access_token是公众号的全局唯一票据,公众号调用微信平台各接口时都需使用access_token。我们要主动给微信平台发送客服消息,该access_token就是我们的凭证。在构造和下发客服消息前,我们需要获取这个access_token。

access_token的有效期为2小时(7200s),我们获取一次,两小时内均可使用。微信公众平台开发文档也给出了access_token获取、保存以及刷新的技术建议。但我们这里仅是Demo,无需考虑这么多。

通过https GET请求,我们可以得到属于我们的access_token,请求line为:

https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

golang提供了默认的http client实现,通过默认的client实现我们可以很容器的获取access_token。

const (
        token               = "wechat4go"
        appID               = "wx8e0fb2659c2eexxx"
        appSecret           = "22746009b0162fe50cb915851c53fyyy"
        accessTokenFetchUrl = "https://api.weixin.qq.com/cgi-bin/token"
)

func fetchAccessToken() (string, float64, error) {
        requestLine := strings.Join([]string{accessTokenFetchUrl,
                "?grant_type=client_credential&appid=",
                appID,
                "&secret=",
                appSecret}, "")

        resp, err := http.Get(requestLine)
        if err != nil || resp.StatusCode != http.StatusOK {
                return "", 0.0, err
        }

        defer resp.Body.Close()
        body, err := ioutil.ReadAll(resp.Body)
        if err != nil {
                return "", 0.0, err
        }

        fmt.Println(string(body))
        … …
}

无论成功与否,微信平台都会返回一个包含json数据的应答:

如果获取正确,那么应答里的Json数据为:

{"access_token":"0QCeHwiRtPRUCiM5MM0cSPYIP5QOUNYdb8usRSgVZcsFuVF6mu3vQq41OIifJdrtJPGn7b1x90HdvUanpb7eZHxg40B6bU_Sgszh2byyF40","expires_in":7200}

如果获取错误,那么应答里的Json数据为:

{"errcode":40001,"errmsg":"invalid credential"}

和xml数据包一样,golang也提供了json格式数据包的Marshal和Unmarshal方法,且使用方式相同,也是将一个json数据包与一 个struct对应起来。从上面来看,通过http response,我们无法区分出是否成功获取了token,因此我们需要首先判断试下body中是否包含某些特征字符串,比 如"access_token":

if bytes.Contains(body, []byte("access_token")) {
    //unmarshal to AccessTokenResponse struct
} else {
    //unmarshal to AccessTokenErrorResponse struct
}

针对获取成功以及失败的两种Json数据,我们定义了两个结构体:

type AccessTokenResponse struct {
        AccessToken string  `json:"access_token"`
        ExpiresIn   float64 `json:"expires_in"`
}

type AccessTokenErrorResponse struct {
        Errcode float64
        Errmsg  string
}

Json unmarshal的代码片段如下:

//Json Decoding
if bytes.Contains(body, []byte("access_token")) {
        atr := AccessTokenResponse{}
        err = json.Unmarshal(body, &atr)
        if err != nil {
            return "", 0.0, err
        }
        return atr.AccessToken, atr.ExpiresIn, nil
} else {
        fmt.Println("return err")
        ater := AccessTokenErrorResponse{}
        err = json.Unmarshal(body, &ater)
        if err != nil {
            return "", 0.0, err
        }
        return "", 0.0, fmt.Errorf("%s", ater.Errmsg)
}

我们的main函数如下:
func main() {
        accessToken, expiresIn, err := fetchAccessToken()
        if err != nil {
                log.Println("Get access_token error:", err)
                return
        }
        fmt.Println(accessToken, expiresIn)
}

编译执行,成功获取access_token的输出如下:

0QCeHwiRtPRUCiM5MM0cSPYIP5QOUNYdb8usRSgVZcsFuVF6mu3vQq41OIifJdrtJPGn7b1x90HdvUanpb7eZHxg40B6bU_Sgszh2byyF40 7200

失败时,输出如下:

2014/12/30 12:39:56 Get access_token error: invalid credential

二、发送客服消息

平台开发文档中定义了文本客服消息的body格式,一个json数据:

{
    "touser":"OPENID",
    "msgtype":"text",
    "text":
    {
         "content":"Hello World"
    }
}

其中的touser填写的是openid。之前的文章中提到过,每个微信用户针对某一个订阅号/服务号都有唯一的OpenID,这个ID可以在微信订阅号 /服务号管理页面中看到,也可以在收到的微信平台转发的消息中看到(FromUserName)。比如我个人订阅的我的测试体验号后得到的OpenID 为:

BQcwuAbKpiSAbbvd_DEZg7q27QI

我们要做的就是构造这样一个json数据,并放入HTTP Post包中,发到:

https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN

从平台开发文档给出的json数据包样例来看,这是个嵌套json数据包,我们通过下面方法marshall:

type CustomServiceMsg struct {
        ToUser  string         `json:"touser"`
        MsgType string         `json:"msgtype"`
        Text    TextMsgContent `json:"text"`
}

type TextMsgContent struct {
        Content string `json:"content"`
}

func pushCustomMsg(accessToken, toUser, msg string) error {
        csMsg := &CustomServiceMsg{
                ToUser:  toUser,
                MsgType: "text",
                Text:    TextMsgContent{Content: msg},
        }

        body, err := json.MarshalIndent(csMsg, " ", "  ")
        if err != nil {
                return err
        }
        fmt.Println(string(body))
        … …
}

如果单纯输出上面marshal的结果,可以看到:

{
   "touser": "oBQcwuAbKpiSAbbvd_DEZg7q27QI",
   "msgtype": "text",
   "text": {
     "content": "你好"
   }
 }

接下来将marshal后的[]byte放入一个http post的body中,发送到指定url中:

var openID = "oBQcwuAbKpiSAbbvd_DEZg7q27QI"

func pushCustomMsg(accessToken, toUser, msg string) error {
        … …

        postReq, err := http.NewRequest("POST",
                strings.Join([]string{customServicePostUrl, "?access_token=", accessToken}, ""),
                bytes.NewReader(body))
        if err != nil {
                return err
        }

        postReq.Header.Set("Content-Type", "application/json; encoding=utf-8")

        client := &http.Client{}
        resp, err := client.Do(postReq)
        if err != nil {
                return err
        }
        resp.Body.Close()

        return nil
}

我们在main函数中加上客服消息的发送环节:

func main() {
        // Fetch access_token
        accessToken, expiresIn, err := fetchAccessToken()
        if err != nil {
                log.Println("Get access_token error:", err)
                return
        }
        fmt.Println(accessToken, expiresIn)

        // Post custom service message
        msg := "你好"
        err = pushCustomMsg(accessToken, openID, msg)
        if err != nil {
                log.Println("Push custom service message err:", err)
                return
        }
}

编译执行,手机响起提示音,打开观看,微信公众平台测试号发来消息:“你好”。

上述Demo完整代码在这里可以看到,别忘了appID,appSecret改成你自己的值。

目前客服接口仅提供给认证后的订阅号以及服务号,对于未认证的订阅号,无法发送客服消息。

使用Golang开发微信公众平台-接收加密消息

No Comments

在上一篇“接收文本消息”一文中,我们了解到:公众服务与微信服务器间的消息是“裸奔”的(即明文传输,通过抓包可以看到)。显然这对于一些对安 全性要求较高的大企业服务号来说,比如银行、证券、电信运营商或航空客服等是不能完全满足要求的。于是乎就有了微信服务器与公众服务间的数据加密 通信流程。

公众号管理员可以在公众号“开发者中心”选择是否采用"安全模式"(区别于明文模式):

一旦选择了“安全模式”,微信服务器在向公众号服务转发消息时会对XML数据包部分内容进行加密处理。这类加密后的请求Body中的XML数据变 成了下面这样:

xml数据基本结构变成了:

<xml>
    <ToUserName>xx</ToUserName>
    <Encrypt>xx</Encrypt>
</xml>

另外在“安全模式”下,Http Post Request line中也增加了两个字段:encrypt_typemsg_signuature,用于消息类型判断以及加密消息内容有效性校验:

POST /?signature=891789ec400309a6be74ac278030e472f90782a5&timestamp=1419214101&nonce=788148964&encrypt_type=aes&msg_signature=87d7b127fab3771b452bc6a592f530cd8edba950 HTTP/1.1\r\n

其中:

encrypt_type = "aes",说明是加密消息,否则为"raw”,即未加密消息。
msg_signature=sha1(sort(Token, timestamp, nonce, msg_encrypt))

对于测试号,测试号配置页面没有加密相关配置,因此只能通过“微信公众平台接口调试工具”来进行相关加密接口调试。

一、消息签名验证

对于“安全模式”下的消息交互,首先要做的就是消息签名验证,只有通过验证的消息才会进行下一步解密、解析和处理。

消息签名验证的原理是比较微信平台HTTP Post Line中携带的msg_signature与通过Token、timestamp、nonce和msg_encrypt等四个字段值计算出的 msg_signture是否一致,一致则通过消息签名验证。

我们依旧在procRequest中完成对“安全模式”下消息的签名验证。

//recvencryptedtextmsg.go
type EncryptRequestBody struct {
        XMLName    xml.Name `xml:"xml"`
        ToUserName string
        Encrypt    string
}

func makeMsgSignature(timestamp, nonce, msg_encrypt string) string {
        sl := []string{token, timestamp, nonce, msg_encrypt}
        sort.Strings(sl)
        s := sha1.New()
        io.WriteString(s, strings.Join(sl, ""))
        return fmt.Sprintf("%x", s.Sum(nil))
}

func validateMsg(timestamp, nonce, msgEncrypt, msgSignatureIn string) bool {
        msgSignatureGen := makeMsgSignature(timestamp, nonce, msgEncrypt)
        if msgSignatureGen != msgSignatureIn {
                return false
        }
        return true
}

func parseEncryptRequestBody(r *http.Request) *EncryptRequestBody {
        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
                log.Fatal(err)
                return nil
        }
        requestBody := &EncryptRequestBody{}
        xml.Unmarshal(body, requestBody)
        return requestBody
}

func procRequest(w http.ResponseWriter, r *http.Request) {
        r.ParseForm()

        timestamp := strings.Join(r.Form["timestamp"], "")
        nonce := strings.Join(r.Form["nonce"], "")
        signature := strings.Join(r.Form["signature"], "")
        encryptType := strings.Join(r.Form["encrypt_type"], "")
        msgSignature := strings.Join(r.Form["msg_signature"], "")

        … …

        f r.Method == "POST" {
                if encryptType == "aes" {
                        log.Println("Wechat Service: in safe mode")
                        encryptRequestBody := parseEncryptRequestBody(r)
                       
                        //Validate msg signature
                        if !validateMsg(timestamp, nonce, encryptRequestBody.Encrypt, msgSignature) {
                                log.Println("Wechat Service: msg_signature is invalid")
                                return
                        }
                        log.Println("Wechat Service: msg_signature validation is ok!")
                … …
        }
        … …
}

程序编译执行结果如下:
$sudo ./recvencryptedtextmsg
2014/12/22 13:15:56 Wechat Service: Start!

用手机微信发送一条消息给公众号,程序输出如下结果:

2014/12/22 13:17:35 Wechat Service: in safe mode
2014/12/22 13:17:35 Wechat Service: msg_signature validation is ok!

二、数据包解密

到目前为止,我们已经得到了经过消息验证ok的加密数据包EncryptRequestBody 的Encrypt。要想得到真正的消息内容,我们需要对Encrypt字段的值进行解密处理。微信采用的是AES加解密方案, 下面我们就来看看如何做AES解密。

在开发者中心选择转换为“安全模式”时,有一个字段EncodingAESKey需要填写,这个字段固定为43个字符,它就是我们在运用AES算 法时需要的那个Key。不过这个EncodingAESKey是被编了码的,真正用来加解密的AESKey需要我们自己通过解码得到。解码方法 为:

AESKey=Base64_Decode(EncodingAESKey + “=”)

Base64 decode后,我们就得到了一个32个字节的AESKey,可以看出微信加密解密用的是AES-256算法(256=32x8bit)。

在Golang中,我们可以通过下面代码得到真正的AESKey:

const (
        token = "wechat4go"
        appID = "wx5b5c2614d269ddb2"
        encodingAESKey = "kZvGYbDKbtPbhv4LBWOcdsp5VktA3xe9epVhINevtGg"
)

var aesKey []byte

func encodingAESKey2AESKey(encodingKey string) []byte {
        data, _ := base64.StdEncoding.DecodeString(encodingKey + "=")
        return data
}

func init() {
        aesKey = encodingAESKey2AESKey(encodingAESKey)
}

有了AESKey,我们再来解密数据包。微信公众平台开发文档给出了加密数据包的解析步骤:

1. aes_msg=Base64_Decode(msg_encrypt)
2. rand_msg=AES_Decrypt(aes_msg)
3. 验证尾部$AppId是否是自己的AppId,相同则表示消息没有被篡改,这里进一步加强了消息签名验证
4. 去掉rand_msg头部的16个随机字节,4个字节的msg_len和尾部的$AppId即为最终的xml消息体

微信Wiki中如果能用一个简单的图来说明Base64_Decode后的数据格式就更好了。这里进一步说明一下,解密后的数据,我们称之 plainData,它由四部分组成,按先后顺序排列分别是:

1、随机值       16字节
2、xml包长度    4字节 (注意以BIG_ENDIAN方式读取)
3、xml包  (*这部分数据的长度由上一个字段标识,这个包等价于一个完整的文本接收消息体数据,从ToUsername到MsgID都 有)
4、appID

其中第三段xml包是一个完整的接收文本数据包,与“接收消息”一文中的标准文本数据包格式一致,这就方便我们解析了。好了,下面用代码阐述解 密、解析过程以及appid验证:

procRequest中,增加如下代码:

// Decode base64
cipherData, err := base64.StdEncoding.DecodeString(encryptRequestBody.Encrypt)
if err != nil {
        log.Println("Wechat Service: Decode base64 error:", err)
        return
}

// AES Decrypt
plainData, err := aesDecrypt(cipherData, aesKey)
if err != nil {
        fmt.Println(err)
        return
}

//Xml decoding
textRequestBody, _ := parseEncryptTextRequestBody(plainData)
fmt.Printf("Wechat Service: Recv text msg [%s] from user [%s]!",
            textRequestBody.Content,
            textRequestBody.FromUserName)

根据解密方法,我们先对encryptRequestBody.Encrypt进行base64 decode操作得到cipherData,再用aesDecrypt对cipherData进行解密得到上面提到的由四部分组成的plainData。plainData经过xml decoding后就得到我们的TextRequestBody struct。

这里难点显然在aesDecrypt的实现上了。微信的加密包采用aes-256算法,秘钥长度32B,采用PKCS#7 Padding方式。Golang提供了强大的AES加密解密方法,我们利用这些方法实现微信包的解密:

func aesDecrypt(cipherData []byte, aesKey []byte) ([]byte, error) {
        k := len(aesKey) //PKCS#7
        if len(cipherData)%k != 0 {
                return nil, errors.New("crypto/cipher: ciphertext size is not multiple of aes key length")
        }

        block, err := aes.NewCipher(aesKey)
        if err != nil {
                return nil, err
        }

        iv := make([]byte, aes.BlockSize)
        if _, err := io.ReadFull(rand.Reader, iv); err != nil {
                return nil, err
        }

        blockMode := cipher.NewCBCDecrypter(block, iv)
        plainData := make([]byte, len(cipherData))
        blockMode.CryptBlocks(plainData, cipherData)
        return plainData, nil
}

对于解密后的plainData做appID校验以及xml Decoding处理如下:

func parseEncryptTextRequestBody(plainText []byte) (*TextRequestBody, error) {
        fmt.Println(string(plainText))

        // Read length
        buf := bytes.NewBuffer(plainText[16:20])
        var length int32
        binary.Read(buf, binary.BigEndian, &length)
        fmt.Println(string(plainText[20 : 20+length]))

        // appID validation
        appIDstart := 20 + length
        id := plainText[appIDstart : int(appIDstart)+len(appID)]
        if !validateAppId(id) {
                log.Println("Wechat Service: appid is invalid!")
                return nil, errors.New("Appid is invalid")
        }
        log.Println("Wechat Service: appid validation is ok!")

        // xml Decoding
        textRequestBody := &TextRequestBody{}
        xml.Unmarshal(plainText[20:20+length], textRequestBody)
        return textRequestBody, nil
}

编译执行输出textRequestBody:

&{{ xml} gh_6ebaca4bb551 on95ht9uPITsmZmq_mvuz4h6f6CI 1.419239875s text Hello, Wechat 6095588848508047134}

三、响应消息的数据包加密

微信公众平台开发文档要求:公众账号对密文消息的回复也要求加密。

对比一下普通的响应消息格式和加密后的响应消息格式:

加密后:

我们定义一个结构体映射响应消息数据包:

type EncryptResponseBody struct {
        XMLName      xml.Name `xml:"xml"`
        Encrypt      CDATAText
        MsgSignature CDATAText
        TimeStamp    string
        Nonce        CDATAText
}

type CDATAText struct {
        Text string `xml:",innerxml"`
}

我们要做的就是给EncryptResponseBody的实例逐一赋值,然后通过xml.MarshalIndent转成xml数据流即可,各字 段值生成规则如下:

Encrypt = Base64_Encode(AES_Encrypt [random(16B)+ msg_len(4B) + msg + $AppId])
MsgSignature=sha1(sort(Token, timestamp, nonce, msg_encrypt))
TimeStamp = 用请求中的值或新生成
Nonce = 用请求中的值或新生成

微信公众接口的加密复杂度要比解密高一些,关键问题在于加密结果的判定和加密逻辑的调试,AES加密出的结果每次都不同,我们要么通过微信平台真实操作验证,要么通过微信提供的在线调试工具验证加密是否正确。这里强烈建议使用在线调试工具(测试号只能选择这一种)。

在线调试工具的配置参考如下,ToUserName和FromUserName建议填写真实的(通过解密Post包打印输出得到):

如果在线调试工具收到你的应答,并解密成功,会给出如下反馈:

在procRequest中,我们在接收解析完Http Request后,通过下面几行代码构造一个加密的Response返回给微信平台或调试工具:

responseEncryptTextBody, _ := makeEncryptResponseBody(textRequestBody.ToUserName,
                                textRequestBody.FromUserName,
                                "Hello, "+textRequestBody.FromUserName,
                                nonce,
                                timestamp)
w.Header().Set("Content-Type", "text/xml")
fmt.Fprintf(w, string(responseEncryptTextBody))

func makeEncryptResponseBody(fromUserName, toUserName, content, nonce, timestamp string) ([]byte, error) {
        encryptBody := &EncryptResponseBody{}

        encryptXmlData, _ := makeEncryptXmlData(fromUserName, toUserName, timestamp, content)
        encryptBody.Encrypt = value2CDATA(encryptXmlData)
        encryptBody.MsgSignature = value2CDATA(makeMsgSignature(timestamp, nonce, encryptXmlData))
        encryptBody.TimeStamp = timestamp
        encryptBody.Nonce = value2CDATA(nonce)

        return xml.MarshalIndent(encryptBody, " ", "  ")
}

应答Xml包中只有Encrypt字段是加密的,该字段的生成方式如下:

func makeEncryptXmlData(fromUserName, toUserName, timestamp, content string) (string, error) {
        // Encrypt part3: Xml Encoding
        textResponseBody := &TextResponseBody{}
        textResponseBody.FromUserName = value2CDATA(fromUserName)
        textResponseBody.ToUserName = value2CDATA(toUserName)
        textResponseBody.MsgType = value2CDATA("text")
        textResponseBody.Content = value2CDATA(content)
        textResponseBody.CreateTime = timestamp
        body, err := xml.MarshalIndent(textResponseBody, " ", "  ")
        if err != nil {
                return "", errors.New("xml marshal error")
        }

        // Encrypt part2: Length bytes
        buf := new(bytes.Buffer)
        err = binary.Write(buf, binary.BigEndian, int32(len(body)))
        if err != nil {
                fmt.Println("Binary write err:", err)
        }
        bodyLength := buf.Bytes()

        // Encrypt part1: Random bytes
        randomBytes := []byte("abcdefghijklmnop")

        // Encrypt Part, with part4 - appID
        plainData := bytes.Join([][]byte{randomBytes, bodyLength, body, []byte(appID)}, nil)
        cipherData, err := aesEncrypt(plainData, aesKey)
        if err != nil {
                return "", errors.New("aesEncrypt error")
        }

        return base64.StdEncoding.EncodeToString(cipherData), nil
}

func aesEncrypt(plainData []byte, aesKey []byte) ([]byte, error) {
        k := len(aesKey)
        if len(plainData)%k != 0 {
                plainData = PKCS7Pad(plainData, k)
        }
       
        block, err := aes.NewCipher(aesKey)
        if err != nil {
                return nil, err
        }

        iv := make([]byte, aes.BlockSize)
        if _, err := io.ReadFull(rand.Reader, iv); err != nil {
                return nil, err
        }

        cipherData := make([]byte, len(plainData))
        blockMode := cipher.NewCBCEncrypter(block, iv)
        blockMode.CryptBlocks(cipherData, plainData)

        return cipherData, nil
}

根据官方文档: 微信所用的AES采用的时CBC模式,秘钥长度为32个字节(aesKey),数据采用PKCS#7填充;PKCS#7:K为秘钥字节数(采用32),buf为待加密的内容,N为其字节数。Buf需要被填充为K的整数倍。因此我们pad要加密的数据时,务必pad为k(=32)的整数倍,而不是aes.BlockSize(=16)的整数倍。

采用安全模式后的公众号消息交互性能似乎下降了,发送"hello, wechat"给公众号后好长时间才收到响应。

微信公众号接收加密消息的代码在这里可以下载。这些代码只是演示代码,结构上绝不算优化,大家可以将这些代码封装成通用的接口为后续微信公众平台接口开发奠定基础。

使用Golang开发微信公众平台-接收文本消息

2 Comments

一旦接入验证成功,成为正式开发者,你可能会迫不及待地想通过手机微信发送一条"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&timestamp=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
<xml><ToUserName>oBQcwuAbKpiSAbbvd_DEZg7q27QI</ToUserName><FromUserName>gh_xxxxxxxx</FromUserName><CreateTime>1418979835</CreateTime><MsgType>text</MsgType><Content>Hello, oBQcwuAbKpiSAbbvd_DEZg7q27QI</Content></xml>

三、关于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[文本内容]]>

我们尝试加上为每个文本类型的字段值上直接添加CDATA标记。

func value2CDATA(v string) string {
        return "<![CDATA[" + 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可以看到:

<xml><ToUserName>&lt;![CDATA[oBQcwuAbKpiSAbbvd_DEZg7q27QI]]&gt;&lt;![CDATA[gh_1fd4719f81fe]]&gt;</FromUserName><CreateTime>1419051400</CreateTime><MsgType>&lt;![CDATA[text]]&gt;</MsgType><Content>&lt;![CDATA[Hello, oBQcwuAbKpiSAbbvd_DEZg7q27QI]]&gt;</Content></xml>

可以看到左右尖括号分别被转义为&lt;和&gt;了,这显然不是我们想要的结果。那如何加入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{"<![CDATA[" + 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

本篇文章涉及的代码在这里可以找到。

使用Golang开发微信公众平台-接入验证

2 Comments

今年我涉猎的领域有些“广泛”,并且有那么一点“跳跃”:从上半年的终端(游戏)开发到下半年golangdocker以及目前将要提及的微信公众平台 接口开发,似乎有些远离了老本行C以及技术管理的内容。但在这个转型以及创新驱动的时代,这显然是顺势而为。寻求与新兴领域的主动接轨,在实打实的实践 中,扩大了自己的视野,并可以进一步甄别发现适合自己的领域。

移动互联网时代,微信平台一枝独秀,是社交领域的巨人,但其诞生也才不到4年。微信平台的发展前景十分广阔,企鹅公司将其打造为人与人、人与物、物与物的统一、万能入口之雄心不变,因此围绕微信平台广大开发者依旧有诸多机会。

微信公众平台接口应该算是微信平台首批对外开放的接口吧。公众平台相对成熟,但其业务模式依旧在演进和创新。公众平台接口的开发并非不难,上手几个月就可 以写成一本诸如“微信公众平台应用开发实践”的事情就发生在你我眼前,因此这里后续有关微信公众平台接口开发的文章也都是一些入门级的,我个人也是边学 习,边实践,边记录,边分享,就像上半年写Cocos2d-x文章那样。

一、公众号申请(可选

本着“再小的个体,也有自己的品牌”的微信公众平台产品哲学,只要你是合法自然人类,你就可以到https://mp.weixin.qq.com/上申请一个公众号,一般对于个体而言,只能申请订阅号。

对于具有开发能力的订阅号拥有者,你可以在订阅号的“开发者中心”,启用开发者账号。并且“一旦启用并设置服务器配置后,用户发给公众号的消息以及开发者需要的事件推送,将被微信转发到该URL中”。

不过此时即便你填写相关信息并提交,你也不会通过验证。这正是本篇要告诉你的事情,如何写程序实现微信公众平台的接入验证,后续道来。

二、测试号申请(可选)

正式的订阅号申请有些繁琐,需要提交个人信息,需要审核,不会立即生效。并且未认证的订阅号所能使用的功能接口有限(只能使用普通消息接口),而认证又需 要一笔费用(现价300rmb/次)。对于学习者而言,也许真的没有必要。于是我们在学习开发的过程中可以申请测试号来替代真正的公众号。

测试号是一种体验账号,有效期一年,具有各种功能接口体验权限。测试号可以在http://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login下申请,申请时有一个类似公众号开发者配置的页面需要你填写服务器配置。同样,你需要进行接入验证(后续道来)。

一旦申请成功,可以用终端微信app扫一扫测试号的QR,关注微信平台测试号,用于后续平台接口开发测试。

以上公众号和测试号二选一

三、公众号服务器

为何需要公众号服务器,这就要谈及微信公众平台的架构了。

很多人觉得微信公众平台的业务模式有些类似于若干年前火爆一时的短信增值业务模式-SP/CP模式:

【终端用户】 <—-短信—> 【移动运营商移动增值业务网关】 <—-> 【SP/CP服务器】

微信公众号时代,这个业务模式变成了:

【终端用户】 <—-微信消息—> 【微信公众平台】 <—–> 【公众号服务器】

短信变成了微信,SP/CP变成了公众号。微信公众平台将终端用户发给公众号的信息转发至公众号配置的公众号服务器URL,公众号服务器做业务处理后,将响应信息通过微信公众平台再发给终端用户。因此我们需要实现公众号业务逻辑的公众号服务器。

本文标题里所说的“接入验证”,指的就是微信公众平台对公众号服务器提供服务的URL有效性的验证。我们在填写开发者中心的“接口配置信 息”并提交时,微信公众平台会向配置的公众号服务器的URL发送验证Request,只有公众号服务存在,且按要求返回包含特定信息的Response, 我们才能真正通过微信公众平台的验证,“接口配置信息”才真正生效。

因此我们需要一台放在公网的主机。如果采用Golang开发公众号服务的话,这样的主机只能是独立的VPS,像国内新浪提供的app engine主机不能运行Golang,无法满足要求(当然如果你使用其他语言开发的话,比如PHP,那么可用的主机范围就很广泛了)。

这里建议申请一个亚马逊免费EC2主机(t2.micro型,免费一年,学习够用)用作学习测试使用或者购买像LinodeDigitalOcean的VPS。关于如何申请亚马逊主机可以咨询谷歌和度娘,这里不赘述。

注意:Amazon EC2实例默认采用的时动态IP,instance重启后IP会发生变化。因此可申请分配一个Elastic IP,并绑定在你的EC2实例上,目前绑定instance的Elastic IP是免费的,这个IP在instance重启后不会变更。当你EC2主机到期后,记得释放这个IP,否则就收费了。

四、接入验证逻辑

前面提到过,无论是公众号还是测试号,当你提交配置URL时会收到提交失败的信息,这是微信公众平台接入验证失败所致。在公众平台开发者文档中,关于URL验证逻辑如下:

开发者提交信息(包括URL、Token)后,微信服务器将发送Http Get请求到填写的URL上,GET请求携带四个参数:signature、timestamp、nonce和echostr。公众号服务程序应该按如下要求进行接入验证:

1. 将token、timestamp、nonce三个参数进行字典序排序
2. 将三个参数字符串拼接成一个字符串进行sha1加密
3. 将加密后获得的字符串与signature对比,如果一致,说明该请求来源于微信
4. 如果请求来自于微信,则原样返回echostr参数内容

以上完成后,接入验证就会生效,开发者配置提交就会成功。

列出Http抓包分析后的文本,理解起来就更容易些:

微信服务器发出的验证Request如下:

GET /?signature=d01007dcff994c555bc51d22e154956ccdc61ec5&timestamp=1418970951&nonce=484765335&echostr=qwe1235 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: 0\r\n

应答返回如下:
HTTP/1.0 200 OK\r\n
Date: Fri, 19 Dec 2014 06:35:59 GMT\r\n
Content-Length: nn\r\n
Content-Type: text/plain; charset=utf-8\r\n

qwe1235

五、参考实现

环境:AWS t2.micro ubuntu 14.04 x86_64 Server
     go 1.4

Go语言标准库提供了一个强大的http server,我们直接利用这个server来处理微信平台的Url验证请求。另外微信平台发给公众平台服务器的http request都是请求到"/"下的,这样我们的service无需设置太多http route。

//urlvalidation.go
package main

import (
        "crypto/sha1"
        "fmt"
        "io"
        "log"
        "net/http"
        "sort"
        "strings"
)

const (
        token = "wechat4go"
)

func makeSignature(timestamp, nonce string) string {
        sl := []string{token, timestamp, nonce}
        sort.Strings(sl)
        s := sha1.New()
        io.WriteString(s, strings.Join(sl, ""))
        return fmt.Sprintf("%x", s.Sum(nil))
}

func validateUrl(w http.ResponseWriter, r *http.Request) bool {
        timestamp := strings.Join(r.Form["timestamp"], "")
        nonce := strings.Join(r.Form["nonce"], "")
        signatureGen := makeSignature(timestamp, nonce)

        signatureIn := strings.Join(r.Form["signature"], "")
        if signatureGen != signatureIn {
                return false
        }
        echostr := strings.Join(r.Form["echostr"], "")
        fmt.Fprintf(w, echostr)
        return true
}

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!")
}

func main() {
        log.Println("Wechat Service: Start!")
        http.HandleFunc("/", procRequest)
        err := http.ListenAndServe(":80", nil)
        if err != nil {
                log.Fatal("Wechat Service: ListenAndServe failed, ", err)
        }
        log.Println("Wechat Service: Stop!")
}

编译这个go源码,执行urlvalidation。

$> urlvalidation
2014/12/18 17:48:10 Wechat Service: Start!
2014/12/18 17:48:10 Wechat Service: ListenAndServe failed, listen tcp :80: bind: permission denied

程序提示没有权限绑定80端口。80端口只有管理员权限才能绑定,因此我们需要通过sudo方式执行validation。

$ sudo ./urlvalidation
2014/12/18 09:56:29 Wechat Service: Start!

接下来我们回到订阅号开发者中心配置页面或测试号服务器配置页面,点击提交。在我们的公众号服务器后台可以看到如下日志:

2014/12/18 09:56:52 Wechat Service: validateUrl Ok!

同时你的提交也会显示成功,Url已经验证通过,你将正式成为开发者。

如果我们随意构造一个http get 请求发给validate程序,比如:

curl -s http://wechat.tonybai.com(比如我的URL为http://wechat.tonybai.com)

那么我们将看到validation输出如下错误日志:

2014/12/18 10:02:07 Wechat Service: this http request is not from Wechat platform!

以上源码文件在这里可以下载。

处于安全考虑,后续订阅号平台均需要对收到的http request进行验证,以确保请求来源于微信公众平台。

Older Entries