如何使用REST API的方式广播到Azure SignalR Service

在使用Azure SignalR Service的过程中发现它对可以支持REST API,能够同通过直接通过POST请求来访问架设好的Azure SignalR Service,与其他的客户端进行通信。然而文档仅仅是做了比较简单的描述,示例代码不太详细。进行了很多调查研究,终于发现了里面的一些门道。这里分享给大家。

这篇文章包括下面的一些内容

  1. 为什么要使用Azure SignalR Service的REST API。
  2. Azure SignalR Service对RESTFUL API的支持。
  3. 如何做Authentication
  4. 如何打包message

为什么要使用Azure SignalR Service REST API

本文的主要目的是通过RESTful的方式来使用Azure SignalR功能,这样依赖REST的 Client/Service 将有可以实时广播/发送消息。这种方式有一下几种优势:

  1. 数据库服务器可以通过REST服务实时 广播/发送 数据的修改(插入/更新/删除),然后将数据的状态立刻同步给它的订阅者,(有点像Service Hub)。
  2. 应用服务器可以通过RESTful的方式实时的 广播/发送 消息。 例如,使用能够使用REST服务的其他编程语言开发的应用程序将有机会实时广播/发送消息。从而做到应用程序的开发语言无关性。
  3. 物联网硬件(如NetdunioPlus2,Arduino,Raspberry PI)有机会发送有关它的状态的实时消息。
  4. 使用基于内存的缓存机制的Web应用程序可以在缓存过期之前 侦听/接收 对缓存的实时更改,从而不需要重新启动Web应用程序。
  5. 它有助于设计高度去耦系统,这些系统几乎无法相互了解,完全具备REST和SignalR技术的能力。

img

Azure SignalR Service对RESTFUL API的支持。

Azure SignalR Service提供了一组REST API,因此您可以使用任何编程语言或任何REST客户端(如Postman, Curl, Fiddler)从任何地方向连接的客户端发送消息。 REST API的定义在此swagger文件中描述。
目前Azure SignalR Service 仅仅支持下面几种方式的REST API :

API 描述
/api/v1-preview/hub/{hub}/user/{id} 将消息发送到某一指定用户
/api/v1-preview/hub/{hub}/users/{userList} 将消息发送给多个用户
/api/v1-preview/hub/{hub} 向所有连入了hub的用户广播消息
/api/v1-preview/hub/{hub}/group/{group} 将消息广播到指定的group中
/api/v1-preview/hub/{hub}/groups/{groupList} 将消息广播到指定的多个group中

对于swagger.json, 可以通过Swagger Editor 来进行查看。将swagger.json中的内容,COPY到Swagger Editor中。右侧会自动显示API的定义。
img

在右侧,点开某个特定的API后,可以看到详细的描述。在输入指定的参数以后,点[try it out],还能自动生成curl的命令行,非常的方便。
img

特别值得提及的是,Azure SignalR Service只提供端口5002来作为REST API的支持接口。5001则是作为客户端的访问端口。

如何做Authentication

使用过Azure SignalR Service的用户都知道,在使用客户端访问Service的时候,需要提供相应的Key来做authentication. 这一点,在使用Restful API进行访问的时候也不例外,在每一个Http请求,都需要通过Http Header中的authentication header来做authentication. 这个authentication header必须符合JSON Web Token (JWT).
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

JWT是由三段信息构成的,中间由”.”进行分隔。将这三段信息文本用.链接一起就构成了Jwt字符串。就像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

三个部分都有自己明确的意义。

  • 第一部分我们称它为头部(header).
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • 第二部分我们称其为载荷(payload), 信息的内容存放在payload中,
    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
  • 第三部分是签证(signature).
    TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
  • 其中每一段的内容都需要进行base64加密。更多的内容可以参考这里5 Easy Steps to Understanding JSON Web Tokens (JWT)
  • 每一段都需要用”.“进行分隔。

根据Azure SignalR Service Rest API定义,Header 必须为HS256的签名算法

因此Header的组成内容应该为 :

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

可以通过base64url encoder 将上述的内容转换为based64
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

payload & signature

Payload中,按照标准中注册的声明 (建议但不强制使用) :

iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

exp, nbf 以及 iat中均为时间相关的数据。 nbf 和 iat可以复制为当前时间,exp可以设定了当前时间加上一个过期时间。

里面最值得注意的是aud. 按照API的定义, aud 必须通过AccessKey 来对进行签名。这个AccessKey来自于所请求的HTTP Url https://example.service.signalr.net:5002/api/v1-preview/hub/myhub. 这个AccessKey指的是Azure SingalR Service上面的创建出来的AccessKey。它可以在portal上面得到,同时也是connectionstring上面的一部分。
Endpoint=https://youservice.service.signalr.net;AccessKey=**KEYVALUESHERE**=;

在portal上面找到这个accesskey之后,把他们都拼到jwt token的payload部分。下面是拼接payload部分的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

public AzureSignalRConnectionInfo GetServerConnectionInfo(string hubName)
{
var hubUrl = $"{BaseEndpoint}:5002/api/v1-preview/hub/{hubName}";
var token = GenerateJwtBearer(null, hubUrl, null, DateTime.UtcNow.AddMinutes(30), AccessKey);
return new AzureSignalRConnectionInfo
{
Endpoint = hubUrl,
AccessKey = token
};
}

private string GenerateJwtBearer(string issuer, string audience, ClaimsIdentity subject, DateTime? expires, string signingKey)
{
SigningCredentials credentials = null;
if (!string.IsNullOrEmpty(signingKey))
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
}
var jwtTokenHandler = new JwtSecurityTokenHandler();
var token = jwtTokenHandler.CreateJwtSecurityToken(
issuer: issuer,
audience: audience, // https://example.service.signalr.net:5002/api/v1-preview/hub/myhub
subject: subject,
expires: expires,
signingCredentials: credentials); // access key
return jwtTokenHandler.WriteToken(token);
}

这个sample code 生成出来的jwt结果如下 :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1MzIwMTQ3NDMsImV4cCI6MTUzMjAxNjU0MywiaWF0IjoxNTMyMDE0NzQzLCJhdWQiOiJodHRwczovL3NyY2hhdHJvb20uc2VydmljZS5zaWduYWxyLm5ldDo1MDAyL2FwaS92MS1wcmV2aWV3L2h1Yi9jaGF0In0.34gbXBtY6f2Blzm-2xFwb2g8iCy-cIAph6VqXeXTtqk

这段内容,拿到 jwt.io 上面进行解码的话,就能看到我们之前的设定。

img

这段jwt的内容,是作为 header中 作为authentiacation的部分,所有还需要在前面加入相关的内容。整个header字串拼完之后应该是

1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1MzIwMTQ3NDMsImV4cCI6MTUzMjAxNjU0MywiaWF0IjoxNTMyMDE0NzQzLCJhdWQiOiJodHRwczovL3NyY2hhdHJvb20uc2VydmljZS5zaWduYWxyLm5ldDo1MDAyL2FwaS92MS1wcmV2aWV3L2h1Yi9jaGF0In0.VWzp0lLW1OL7-wWfN0nsfxjy7glr6Yq5BSdvE1dRdcE

如何打包message

把HttpHeader组装好之后,开始组装message body,在swagger.json中有描述。
Target 是用来指定客户端将要被invoke的方面名。 Arguments应为要传入的参数。
img
这里,客户端的我使用了Azure Signalr Service Sample中的chatroom. 这个sample中的web app的客户端页面invoke后台的BroadcastMessage方法,并且浏览器上的页面的功能也是订阅在这个方法方法上。一旦Chat hub有消息传入,websocket就会将消息通知给客户端。
我的sample 就是要通过5002的端口,使用RESTFUL的方式,发送http请求来通知到注册在这个hub上面的客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

namespace Microsoft.Azure.SignalR.Samples.ChatRoom
{
public class Chat : Hub
{
public void BroadcastMessage(string name, string message)
{
Clients.All.SendAsync("broadcastMessage", name, message);
}

public void Echo(string name, string message)
{
Clients.Client(Context.ConnectionId).SendAsync("echo", name, message + " (echo from server)");
}
}
}

下面是发送HTTP请求的客户端代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using (var httpClient = new HttpClient())
{
var connectionInfo = signalR.GetServerConnectionInfo("chat");

SignalRMessage item = new SignalRMessage()
{
Arguments = new string[] { "_SYSTEM_","SonicGuo JOINED"},
Target = "broadcastMessage"
};

Program instance = new Program();

var json = instance.PostJsonAsync(httpClient, connectionInfo.Endpoint, item, connectionInfo.AccessKey);

var message = json.Result;

item = new SignalRMessage()
{
Arguments = new string[] { "SonicGuo", "HELLO ALL" },
Target = "broadcastMessage"
};

json = instance.PostJsonAsync(httpClient, connectionInfo.Endpoint, item, connectionInfo.AccessKey);
message = json.Result;

}

发送成功后,通过fiddler看到发送的HTTP请求的内容如下。

1
2
3
4
5
6
7
8
9
POST https://srchatroom.service.signalr.net:5002/api/v1-preview/hub/chat HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1MzIwMTQ3NDMsImV4cCI6MTUzMjAxNjU0MywiaWF0IjoxNTMyMDE0NzQzLCJhdWQiOiJodHRwczovL3NyY2hhdHJvb20uc2VydmljZS5zaWduYWxyLm5ldDo1MDAyL2FwaS92MS1wcmV2aWV3L2h1Yi9jaGF0In0.VWzp0lLW1OL7-wWfN0nsfxjy7glr6Yq5BSdvE1dRdcE
Accept: application/json
Accept-Charset: UTF-8
Content-Type: application/json; charset=utf-8
Content-Length: 72
Host: srchatroom.service.signalr.net:5002

{"Target":"broadcastMessage","Arguments":["_SYSTEM_","SonicGuo JOINED"]}

发送成功会收到202的返回,表示accepted. 其他的客户端(网页)就会收到这样的广播消息。
img

Sonic Guo