【WP 8.1开发】推送通知测试服务端程序
所谓推送通知,用老爷爷都能听懂的话说,就是:
1、我的服务器将通知内容发送到微软的通知服务器,再由通知服务器帮我转发消息。
2、那么,微软的推送服务器是如何知道我的服务器要发消息给哪台手机呢?手机客户端应用程序在创建推送通道时,微软的通知服务器会为手机分配一个URL,我的服务器只要知道这个URL就可以向指定的手机发送消息。所以,手机客户端必须通过网络把获取到的手机URL发给我的服务器,方法很多,如使用Socket、HTTP提交、Web服务、WCF等都可以。
要测试推送通知,可以通过WP 8.1的模拟器的模拟通知功能来实现,但是,这个模拟通知功能目前不太稳定,为啥不稳定呢?我发现可能与Hyper-V的虚拟交换机有关,如果人品好的时候,就可以顺利完成模拟通知;如果哪天人品值波动,就有可能出现错误。
话又说回来,模拟终究是模拟,假的!如果不进行真实的推送测试,说不定你的应用给用户使用时发生意外情况,那就非同一般的痛苦了。对于手机用于接收通知的URL,在调试的时候,可以通过DeBug类输出,然后我们像厦大的教授写论文一样,直接按Ctrl + C,再在服务端Ctrl+V一下就行了。
调试归调试,在实际运作中,应用运行在用户的手机上,你不可能拿每个用户的爱机来debug一下,然后再取得URL的,显然这种做法只能在神话故事才可行,在人间是行不通的。因而我们在开发WP应用时,一定要注意通过网络把手机的通道URL发送到我们的服务器,有了URL才能向某手机发送消息。这就好像你得知道妹子的手机号码,才能向妹子发短信一样,当然,现在人们都用微信来找妹子了。
为了能学会如何使用WP的推送通知,我们首先得有一个服务器,当然不是叫大家去买一台服务器,我们自己动手,写一个测试服务器就行了。
要实现推送,我们必须拥有开发者帐号,至于如何获得,呵呵,方法多着。你可以付款去购买一个,这样的开发者帐号限制较少,可以提交桌面应用;如果你是学生当然首选学生帐号;如果你既不是学生也不想花钱,也是可以的,微软做了一个WP App Studio,是web版的开发工具,不过大家莫笑,这个是给不会编程的人或小孩子玩的,说不得专业。但是,注凹App Studio是免费的,这是重点。至于如何注册,我相信各位都玩过微博、QQ、人人网等东东了,不用我介绍了,无非是填资料,下一步,下一步,完成。如果你不打算在应用商店发布应用,而只是想玩一下,或学习一下,付款信息可以不填,不管它。
创建应用
有了开发者帐号后,我们要在商店中先创建一个应用,应用信息随便填就可以了。如下图,我创建了一个名为“示例应用”的牛X应用程序。
点击应用,进入应用描述页,点击“详细信息”,查看应用的详细信息。
向下滚动页面,找到“Windows推送通知(WNS)”,然后点击超链接进入。
之后会看到这一系列东东。
程序包SID:发送通知的服务器(即我的服务器)在申请Access Token时需要这个,做过微博API的调用的朋友肯定不会陌生,我们在调用微博API前必须得到授权,并换取一个AccessToken,然后我们用这个Token来调用API,相当于一个门卡,通过它可以进入旅店房间。SID除了在申请token时使用,还要把它写到WP应用的清单文件中,即“包名”,两者必须匹配才能允许发送推送消息。
应用程序标识:是应用程序清单文件(实质是XML)中的Identity节点,一定要与“仪表盘”中显示的一致。
客户端ID:完成推送暂时用不到它。
客户端密钥:我的服务器要申请Access Token时需要用到,即我们调用微博API时用到的Secret key,这个一般不要对外公开,开发者自己知道就可以了,不然别人也可以冒充你来发送通知了。
在以上各字段的数据中,要实现推送通知,我们要用到的有:SID、应用程序标识、客户端密钥这三个东东。
开发者身份验证
和微博API调用相似,我们要先验证自己的身份,获得一个access token,才能向指定的手机发送通知,以下是向WNS服务器发送的HTTPS请求的示例。
POST /accesstoken.srf HTTP/1.1 Content-Type: application/x-www-form-urlencoded Host: https://login.live.com Content-Length: 211 grant_type=client_credentials&client_id={SID}&client_secret={客户端密钥}&scope=notify.windows.com
从中,我们看到该请求有以下几个特点:
1、使用HTTPS方案,地址为https://login.live.com/accesstoken.srf;
2、提交方式为POST;
3、内容格式为application/x-www-form-urlencoded;
4、POST的内容包括四个值:grant_type的值固定为client_credentials,这个不用讲,照抄就行;
注意client_id不是“客户端标识”,应该填上SID,不要填错了。client_secret填上“客户端密钥”。
最后那个scope字段也是固定值,照抄就行了,值为notify.windows.com。
发送POST验证成功后,会返回以下内容:
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Length: 422
Content-Type: application/json
{
"access_token":"EgAcAQMAAAAALYAAY/c+Huwi3Fv4Ck10UrKNmtxRO6Njk2MgA=",
"token_type":"bearer"
}
返回的JSON中,access_token的值就是申请的access token的值了,我们就是用这个来传送通知的。
发送通知
在拿到token后,我们就可以用它来发送通知了。如:
POST https://db3.notify.windows.com/?token=AgUAAADCQmTg7OMlCg%2fK0K8rBPcBqHuy%2b1rTSNPMuIzF6BtvpRdT7DM4j%2fs%2bNNm8z5l1QKZMtyjByKW5uXqb9V7hIAeA3i8FoKR%2f49ZnGgyUkAhzix%2fuSuasL3jalk7562F4Bpw%3d HTTP/1.1 Authorization: Bearer EgAaAQMAAAAEgAAACoAAPzCGedIbQb9vRfPF2Lxy3K//QZB79mLTgK X-WNS-RequestForStatus: true X-WNS-Type: wns/toast Content-Type: text/xml Host: db3.notify.windows.com Content-Length: 196 <toast launch=""> <visual lang="en-US"> <binding template="ToastImageAndText01"> <image id="1" src="World" /> <text id="1">Hello</text> </binding> </visual> </toast>
上面所示的请求中,目标URL就是手机客户端注册的通过URL,这就是为什么客户端程序要把URL发送给服务器的原因,如果服务器不知道通道URL,就无法知晓要把消息发送给哪一台手机了。
通知的内容都是以XML的形式表示的(RAW通知除外,RAW是自定义通知),关于这些XML模板,参考文档上有介绍,而且我们也可以通过API来得到这些模板,这个现在先不说,后面我们会提到。注意几个HTTP头。
Authorization——传递服务器所获取到的access token,格式为“Bearer <token>”,Bearer与token之间有空格。
X-WNS-Type——通知的类型。wns/toast表示发送Toast通知;wns/tile表示磁贴通知;wns/badge表示锁屏通知……
其他标头可以参考这里:http://msdn.microsoft.com/zh-cn/library/windows/apps/xaml/hh868245.aspx#pncodes_x_wns_type
开发测试服务器
现在,估计大家对推送通知的过程已有了解,趁热打铁、付诸实践是成为编程高手的重要因素,因此,为了成为编程高手,我们接下来马上开工,开发一个可以用于测试推送通的Win Forms程序。
1、以管理员身份运行VS 2013 Express for Desktop。我一直很喜欢Express版本,虽然我有MSDN订阅,但我还是用Express版,它比较简洁,但已经集成了VS的核心功能,用来做商业开发都没问题,最重要的是这家伙是免费的。偏偏有些垃圾公司喜欢装逼,开发个破产品还要弄个旗舰版,而且许多功能也用不上。为什么要以管理员身份运行呢?因为我计划用WCF服务来接收客户端APP发来的URL。
2、新建一个Windows窗体应用程序,我就不建WPF了,WinForm大家熟悉一点,用最成熟的方式有时候很爽。
3、我们首先完成的功能是如何根据不同通知生成XML模板。做过RT应用开发的朋友肯定会记得,在Windows.UI.Notifications命名空间下,使用ToastNotificationManager类或TileUpdateManager类,或BadgeUpdateManager类的GetTemplateContent方法,可以返回指定通知的XML模板。可是,我们这里建的是桌面应用,那有没有可能,在桌面应用中调用RT的API呢?告诉你,是可以的,但前题是你用的是Win 8.1系统。
来吧,咱们试试看。首先打开VS的“解决方案资源管理器”,选中刚才创建的桌面项目,右击鼠标,从快捷菜单中选择“卸载项目”,如下图。
同样,在项目节点上右击,从菜单中选择“编辑 <项目名>”。
这时候,就会用XML编辑器打开项目文件,找到第一个PropertyGroup节点,注意是第一个,不要找到其他去了,在第一个PropertyGroup节点下,加入一个TargetPlatformVersion节点,版本号为8.1。
<TargetPlatformVersion>8.1</TargetPlatformVersion>
然后保存并关闭文件。重新加载项目,在添加引用对话框中,就会看到Windows 8.1的选项卡了。
但是,这里列出的“Windows”程序集不是WP8.1的,通过下面的浏览按钮,找到C:\Program Files (x86)\Windows Phone Kits\8.1\References\CommonConfiguration\Neutral目录下的Windows.winmd文件,这才是WP 8.1的运行时API所在的程序集。另外,为了能正常访问,还要添加以下程序集:
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\Facades\System.Runtime.InteropServices.dll
C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETCore\v4.5.1\System.Runtime.WindowsRuntime.dll
其中,System.Runtime.InteropServices.dll不一定要添加,但建议添加,因为如果不加,个别API无法附加事件处理程序。
现在,我们这个WinForm程序就可以访问WP8.1中的API了。
我正是要通过这种方法,把所有的通知模板的XML文档都读出来。
下面是部分代码:
private Type enumType = null; void cmbNotifiType_SelectedIndexChanged(object sender, EventArgs e) { ComboBox cb = sender as ComboBox; if (cb.SelectedIndex == -1) return; string[] enumNames = null; // 判断通知类型 switch (cb.SelectedIndex) { case 0: //Toast通知 enumType = typeof(ToastTemplateType); break; case 1: //磁贴通知 enumType = typeof(TileTemplateType); break; case 2: //锁屏通知 enumType = typeof(BadgeTemplateType); break; case 3: //自定义通知 enumType = null; //无 break; } enumNames = enumType == null ? null : Enum.GetNames(this.enumType); cmbTemplate.DataSource = enumNames; } void cmbTemplate_SelectedIndexChanged(object sender, EventArgs e) { if (cmbTemplate.SelectedIndex == -1) { return; } // 显示内容 if (enumType == typeof(ToastTemplateType)) { ToastTemplateType tem = (ToastTemplateType)Enum.Parse(enumType, cmbTemplate.SelectedItem as string); XmlDocument doc = ToastNotificationManager.GetTemplateContent(tem); txtContent.Text = doc.GetXml(); } else if (enumType == typeof(TileTemplateType)) { TileTemplateType tem = (TileTemplateType)Enum.Parse(enumType, cmbTemplate.SelectedItem as string); XmlDocument doc = TileUpdateManager.GetTemplateContent(tem); txtContent.Text = doc.GetXml(); } else if (enumType == typeof(BadgeTemplateType)) { BadgeTemplateType tem = (BadgeTemplateType)Enum.Parse(enumType, cmbTemplate.SelectedItem as string); XmlDocument doc = BadgeUpdateManager.GetTemplateContent(tem); txtContent.Text = doc.GetXml(); } else { txtContent.Text = string.Empty; } }
通过Enum.GetNames方法可以把一个枚举类型在所有值的名字取出,以字符串数组的形式返回,使用这思路,我们可以将ToastTemplateType、TileTemplateType、BadgeTemplateType几个枚举的值的名称全部读出,显示在下拉列表框(ComboBox)中,当我们在界面上选择一个值时,又通过Enum.Parse方法从枚举值的名称生成枚举实例。最后可以通过GetTemplateContent方法来获取XML文档了。
这样做的好处在于,我们不用手动去准备XML文档。
4、打开Program.cs文件,在Program类中添加用于接收URL的代码。
static class Program { /// <summary> /// 应用程序的主入口点。 /// </summary> [STAThread] static void Main() { HttpListener listener = new HttpListener(); listener.Prefixes.Add("http://+:85/svr/"); try { listener.Start(); listener.BeginGetContext(new AsyncCallback(OnGetAcceptCallback), listener); } catch { } /* WinForm项目生成的代码 */ try { listener.Stop(); } catch { } } private static void OnGetAcceptCallback(IAsyncResult ar) { HttpListener listener = (HttpListener)ar.AsyncState; try { var context = listener.EndGetContext(ar); string url; using (var stream = context.Request.InputStream) { long len = context.Request.ContentLength64; byte[] buffer = new byte[len]; stream.Read(buffer, 0, buffer.Length); url = System.Text.Encoding.UTF8.GetString(buffer); } if (OnGetUrl != null) { OnGetUrl(url); } } catch { } try { listener.BeginGetContext(new AsyncCallback(OnGetAcceptCallback), listener); } catch { } } // 当收到WP客户端应用发来的URL时会触发该事件 public static event Action<string> OnGetUrl; }
这里我选用HttpListener对象来监听HTTP请求,注意如果你用真实手机发送时,要配置一下防火墙,如果你嫌麻烦,就暂时把防火墙关了。地址http://+:85/svr/表示监听本机所有地址,如http://192.168.1.50:85/svr/,端口号是85,当然你可以根据实际情况自己改一下。
Program类中还定义了一个静态事件OnGetUrl,当接收到手机发来的URL后会引发这个事件,我们在主窗口中可以通过处理该事件来在用户界面上显示URL。如
this.Load += (s1, a1) => { Program.OnGetUrl += GetUrlService_OnUrlGot; }; this.FormClosed += (s2, a2) => { Program.OnGetUrl -= GetUrlService_OnUrlGot; }; …… void GetUrlService_OnUrlGot(string url) { BeginInvoke(new Action(() => { if (cmbURLs.FindString(url) < 0) { cmbURLs.Items.Add(url); } })); }
当收到URL后,将URL放进一个ComboBox控件的下拉列表中。
5、下面要完成access token验证功能的实现。
// 发起请求 using (HttpClient client = new HttpClient()) { // 准备要提交的数据 IDictionary<string, string> formdata = new Dictionary<string, string>() { { "grant_type", "client_credentials" }, /* 固定值 */ { "client_id", txtSID.Text.Trim() }, /* SID */ { "client_secret", txtSecret.Text.Trim() }, /* 客户端密钥,勿公开 */ { "scope", "notify.windows.com" } /* 固定值 */ }; FormUrlEncodedContent content = new FormUrlEncodedContent(formdata.AsEnumerable()); // POST,并获取返回数据 btnVertify.Enabled = false; HttpResponseMessage resp = await client.PostAsync("https://login.live.com/accesstoken.srf", content); btnVertify.Enabled = true; if (resp.StatusCode == HttpStatusCode.OK) //成功 { System.IO.Stream streamIn = await resp.Content.ReadAsStreamAsync(); // 反序列化,得到access token DataContractJsonSerializer js = new DataContractJsonSerializer(typeof(OAuth2Data)); OAuth2Data odata = (OAuth2Data)js.ReadObject(streamIn); streamIn.Close(); if (odata != null) { // 显示token txtToken.Text = odata.AccessToken; } } else { StringBuilder sb = new StringBuilder(); foreach (var hd in resp.Headers) { sb.AppendLine(hd.Key + " : " + string.Join(",", hd.Value.ToArray())); } MessageBox.Show(string.Format("验证失败,错误码:{0}。\n{1}", (int)resp.StatusCode, sb.ToString())); }
前面说过,申请token需要向服务器POST格式为application/x-www-form-urlencoded的数据。上面代码中我用的是比较智能的HttpClient类,它可以很轻松地向服务器发送Content,此处应选用FormUrlEncodedContent类。注意这个类比较智能,它会自动帮我们做URL编码处理,因此我们放进去的内容是不需要手动编码的,如果将已编码的内容放进去,会导致重复编码,导致意外错误,token申请失败。
Access Token是以一个JSON对象的形式出现的。还记得吗,上文中我们说过的。如何处理服务器返回的JSON呢?最简单的方法是使用反序列化,直接把JSON对象反序列化为一个类实例就好了。
用来封装token的类定义如下。
[DataContract] public class OAuth2Data { [DataMember(Name="access_token")] public string AccessToken { get; set; } [DataMember(Name = "token_type")] public string TokenType { get; set; } }
DataMember特性的Name所指定的值一定要与JSON数据的字段名匹配,否则反序列化时无法识别。
6、接下来完成发送通知的功能。
string wns_type_header = ""; //推送类型 string content_type = "text/xml"; //内容格式标头 if (enumType == typeof(ToastTemplateType)) //toast { wns_type_header = "wns/toast"; } else if (enumType == typeof(TileTemplateType)) //tile { wns_type_header = "wns/tile"; } else if (enumType == typeof(BadgeTemplateType)) //badge { wns_type_header = "wns/badge"; } else { wns_type_header = "wns/raw"; content_type = "application/octet-stream"; } // 验证标头 string author_header = string.Format("Bearer {0}", txtToken.Text); HttpWebRequest request = (HttpWebRequest)WebRequest.Create(cmbURLs.Text); // 添加必要标头 request.ContentType = content_type; request.Headers.Add("X-WNS-Type", wns_type_header); request.Headers.Add("Authorization", author_header); // 添加可选标头 if (ckbSuppressPop.Checked && enumType == typeof(ToastTemplateType)) { request.Headers.Add("X-WNS-SuppressPopup", "true"); } if (ckbWns_Tag.Checked && string.IsNullOrWhiteSpace(txtWNS_Tag.Text) == false) { request.Headers.Add("X-WNS-Tag", txtWNS_Tag.Text); } if (ckbWns_Group.Checked && string.IsNullOrWhiteSpace(txtWns_Group.Text) == false) { request.Headers.Add("X-WNS-Group", txtWns_Group.Text); } // POST request.Method = "POST"; // 内容 byte[] data = Encoding.UTF8.GetBytes(txtContent.Text); //request.ContentLength = data.Length; // 写入 using (var streamout = request.GetRequestStream()) { streamout.Write(data, 0, data.Length); } // 发起请求 HttpWebResponse response = null; try { response = (HttpWebResponse)request.GetResponse(); if (response.StatusCode == HttpStatusCode.OK) { MessageBox.Show("发送成功。"); } else { MessageBox.Show("发送失败。"); } } catch (WebException webex) { …… }
通知的正文是XML文档(RAW通知除外),以下几个标头是必须的:
Authorization:验证后获取的token就通过该头来传递,格式为Bearer <access token>,注意Bearer与Token之间有个空格,之个access token就是我们通过验证后获取的Token。
X-WNS-Type:通知的类型。如果是Toast通知就wns/toast,如果是磁贴通知就wns/tile,反正照着MSDN的文档套就行了,就像抄袭论文一样轻松。
ContentType:基本上都是text/xml,只有RAW通知使用application/octet-stream
然后是一些可选的标头:
X-WNS-Tag和X-WNS-Group可以一起用,tag相当于通知的id,Toast通知比较明显,如果我发送了一条tag为a的Toast通知,然后再发一条tag为b的Toast通知,就算a和b的内容完全一样,但由于tag不同,它们在手机上会显示为两条消息。
如果第一条Toast的tag为c,然后再发一条tag也为c的Toast通知,那么,第二条Toast会替换掉第一条Toast,也就是说始终在手机上只会提示一条通知。
如果用上了X-WNS-Group,就可以对Tag进行分组,同一组下的tag必须唯一,但不同组之间可以存在相同的tag的通知,比如,组1中有一条通知的tag为f1,组2中有一条通知的tag为f1,虽然它们tag相同,但处于不同的组中,因此它们在手机上将显示为两条通知。
X-WNS-SuppressPopup标头是针对Toast通知而言的,如果为false,则Toast会弹出,如下图所示。
如果使用了X-WNS-SuppressPopup了标头,并设置为true,则Toast通知不会弹出来,而直接放进通知中心了。如下图
现在,这个用于测试推送通知的服务器已经完成了,我们可以用它来发送通知了。源码下载地址:https://files.cnblogs.com/tcjiaan/PushNotificationTestServer.rar
下一篇文章咱们来完成WP手机客户端程序,来接收推送通知。