65.9K
CodeProject 正在变化。 阅读更多。
Home

Windows 8 的 UPnP 代码

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2012 年 9 月 13 日

公共领域

16分钟阅读

viewsIcon

104560

downloadIcon

2446

如何在 Windows 8 上使用 UPnP

介绍  

此代码可在 UPnP 扬声器上播放音频。如果您有 DLNA 扬声器(“Digital Lifestyle Networking Alliance”),它们基于 UPnP,此代码也可在它们上面工作。

与其遵循本文,不如采用一个关键的替代技术,该技术更好——使用 Windows 8 对“播放到”(此链接指向 Windows SDK 样本代码,用于 PlayTo 合同+API)的新支持。PlayTo 更简单、经过更充分的测试,并且比自己实现所有 UPnP 内容更可取。它只需要几行代码。您的应用商店应用的用户将能够使用“设备”超级按钮将您的应用流式传输视频或音频到他们的电视、扬声器或相框,前提是设备制造商提供了 Windows 8 认证驱动程序。

(请注意,认证驱动程序仅适用于应用商店应用;桌面应用不需要。还有一个较旧的技术,使用一个名为UPNP.dll 的 COM 库,可追溯到 XP,但它不允许用于应用商店应用)。

我编写此代码是因为我的设备尚未获得 Windows 8 认证驱动程序,并且因为我想了解 UPnP。更广泛地说,当您使用 PlayTo 时,此代码可能有助于您理解幕后工作的 UPnP 基础。

目录

  1. 本文档描述了原始 UPnP 协议,即设备之间涉及的 UDP 和 TCP 消息。
  2. 接下来,它展示了显式实现那些原始 UDP 和 TCP 通信的代码,包括设备发现(SSDP)和设备控制(UPnP)。
  3. 代码有两种形式:.NET45 代码(可在任何 .NET45 计算机上运行,但不能在应用商店中使用)和 WinRT 代码(可在 Windows 平板电脑上运行,可用于应用商店,需要 Windows 8)。

  4. 接下来,它展示了如何使用 HttpClient 和 VB 的 XML 字面量,以实现更高级别的抽象,而不是进行原始 TCP 编程。
  5. 接下来,它展示了如何使用 WinRT 设备枚举 API,而不是手动实现 SSDP。这是一种更清晰的做事方式,可在 Windows 平板电脑上运行,并可用于应用商店,但需要 Windows 8。

使用 UPnP 设备进行测试

以下是三个 UPnP 设备,您可以在它们上面测试此代码:

  • 我出去买了一个 Sony SA-NS300 无线扬声器。这是一个 UPnP 设备(但截至 2012 年 9 月尚未获得 Windows 8 认证驱动程序)。
  • 我下载了UPnP 技术开发工具。其中包含一个名为MediaRenderer.exe 的程序,您可以在上面进行测试(但它也尚未获得 Windows 8 认证)。
  • 我在同一网络上设置了另一台计算机,在其上运行 Windows Media Player,并执行了“流”>“允许远程控制”。(这必须在另一台计算机上进行:它在同一计算机上不起作用)。此技术也在 Windows SDK 的“播放到示例”中有描述。它获得了 Windows 8 认证。

这是我的笔记本电脑同时无线向所有三个设备发送音频的图片!

WinRT 枚举中的配对:WinRT 设备枚举 API 只会报告“已配对”的设备。在家庭/私有网络上,设备将自动配对,但在公共网络上,您必须手动配对(开始屏幕 > 控制面板 > 设备 > 添加新设备)。也许这个“配对”功能是为了阻止您在公共网络上意外广播不该广播的内容?我的 SSDP 代码缺少此功能。

手动 SSDP 搜索的局限性:我的手动 SSDP 发现代码似乎找不到 WinRT 能够枚举或在网络邻居中显示的那么多设备。Windows 可能会枚举通过 SSDP 以外的多种发现协议发现的设备。

UPnP 协议

UPnP 在 www.upnp.org 上有出色的文档。

  • UPnP 设备架构 - 解释了共享 UPnP 协议的工作原理,包括服务发现。这是所有 UPnP 协议的通用信息。其余文档特定于媒体播放。
  • UPnP AV 架构 - 解释了媒体渲染的特定情况,从 DMS(数字媒体源)到 DMR(数字媒体渲染器)。
  • ConnectionManager:1 - 文档记录了ConnectionManager 接口的 API,用于确定支持哪些协议。
  • AVTransport:1 - 文档记录了AVTransport 接口的 API,用于告知 DMR 要播放的媒体源,并启动/停止它。

当媒体通过 UPnP 播放时,涉及三个方:

控制点 - 此方告诉其他方何时播放以及播放什么。

数字媒体渲染器 (DMR) - 扬声器/电视。它们可以被告知要播放的 URL,并且可以被告知开始/停止。

数字媒体源 (DMS) - 媒体的来源,即它提供 DMR 请求的 URL。

您可以想象很多种工作方式。也许您的计算机是控制点,DMS 是您阁楼服务器上的某个存档,而 DMR 是您的电视机。或者,您的计算机可能是 DMS,控制点是平板电脑、手机或遥控器,而 DMR 是您的 Wi-Fi 扬声器。

在本文中,我们将编写一个控制点和 DMS 的控制台应用程序。实际上,这是一个轻量级 DMS,它仅通过 HTTP 提供单个媒体:它不支持 DMS 通常应具备的完整搜索功能等。

UPnP 协议

UPnP 设备通过一种称为 **SSDP** 的协议相互查找,该协议在 UPnP 设备架构文档中有记载。它指定了新设备如何通知现有监听器它们何时加入网络。它还指定了一种更简单的形式,我们在此处描述,您可以通过它找出当前存在的所有设备。

>>> 出站多播 UDP 消息发送到 239.255.255.250:1900],使用 CRLF 行终止符和 UTF8 编码。请注意,消息末尾有一个空行。MX 参数告诉设备在最多 1 秒的随机时间间隔内响应,因此我们至少应该收听那么长时间。此数据包的格式以及响应,都包含在 UPnP 设备架构文档中。

M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
ST:urn:schemas-upnp-org:device:MediaRenderer:1
MAN: "ssdp:discover"
MX: 1

<<< 接收来自每个设备的 UDP 响应,由它们从它们的 ip 地址和任意端口发送,由我们绑定 UDP 套接字的任意 IP+端口接收。响应也使用 CRLF 和 UTF8,并在末尾包含一个空行(但由于它适合一个数据包,所以这无关紧要)。

HTTP/1.1 200 OK
ST: urn:schemas-upnp-org:device:MediaRenderer:1
CACHE-CONTROL: max-age=900
EXT:
USN: uuid:93997fb5-25f6-4385-8356-c705f0e7e0d9::urn:schemas-upnp-org:device:MediaRenderer:1
SERVER: Windows NT/5.0, UPnP/1.0
LOCATION: http://192.168.0.128:35142/
Content-Length: 0

>>> 出站 TCP 消息,针对每个设备,执行 HTTP-GET 以检索设备 LOCATION: 响应中指定的 URI。在这种情况下,我们创建一个 TCP 套接字,将其连接到 192.168.0.128:35142,并发送以下消息(同样使用 CRLF、UTF8 和末尾的空行)。

GET / HTTP/1.1

<<< 同一 TCP 套接字上的响应。与所有 HTTP 响应一样,头部以 UTF8 读取,并以空行终止,然后是必须以指定编码解释的“Content-Length”字节。(顺便说一句,HttpClient.GetStringAsyncHttpContent.ReadAsStringAsync 中存在一个错误,当字符集用引号括起来时,它们会引发异常,如这里所示。)在此消息中,请注意 UDN(唯一设备名称)以及它支持的三个服务的 ControlURLs。此发现响应消息的格式在 UPnP 架构文档中。

HTTP/1.1 200 OK
CONTENT-TYPE: text/xml;charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
CONTENT-LENGTH: 3169
 
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
  <specVersion><major>1</major><minor>0</minor></specVersion>
  <device>
    <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
    <UDN>uuid:5f9ec1b3-ed59-1900-4530-0007f521ebd6</UDN>
    <friendlyName>SA-NS300</friendlyName>
    <presentationURL>http://192.168.0.122/</presentationURL>
    <manufacturer>Sony Corporation</manufacturer>
    <manufacturerURL>http://www.sony.net/</manufacturerURL>
    <modelDescription>Network Speaker</modelDescription>
    <modelName>SA-NS300</modelName>
    <iconList>
      <icon><mimetype>image/jpeg</mimetype><width>48</width><height>48</height>
            <depth>24</depth><url>/speaker 48.jpg</url></icon>
      <icon><mimetype>image/jpeg</mimetype><width>120</width><height>120</height>
            <depth>24</depth><url>/speaker 120.jpg</url></icon>
      <icon><mimetype>image/png</mimetype><width>48</width><height>48</height>
            <depth>24</depth><url>/speaker 48.png</url></icon>
      <icon><mimetype>image/png</mimetype><width>120</width><height>120</height>
            <depth>24</depth><url>/speaker 120.png</url></icon>
    </iconList>
    <serviceList>
      <service>
        <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
        <SCPDURL>/RenderingControl/desc.xml</SCPDURL>
        <controlURL>/RenderingControl/ctrl</controlURL>
        <eventSubURL>/RenderingControl/evt</eventSubURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
        <SCPDURL>/ConnectionManager/desc.xml</SCPDURL>
        <controlURL>/ConnectionManager/ctrl</controlURL>
        <eventSubURL>/ConnectionManager/evt</eventSubURL>
      </service>
      <service>
        <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
        <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
        <SCPDURL>/AVTransport/desc.xml</SCPDURL>
        <controlURL>/AVTransport/ctrl</controlURL>
        <eventSubURL>/AVTransport/evt</eventSubURL>
      </service>
    </serviceList>
  </device>
</root>

协议的下一阶段是与设备通过其控制接口进行交互。对于此特定设备,一个媒体渲染器,整体协议在“UPnP AV 架构”文档中有所记载。首先,我们选择查询设备支持哪些媒体格式。

>>> 出站 TCP 消息,在设备的 ConnectionManagerUrl 上执行“GetProtocolInfo SOAP 操作”,我们之前已检索到该 URL。如果您愿意,可以在之前使用的同一 TCP 套接字上执行此操作,前提是该套接字仍然打开且位于同一主机上。HTTP/1.1 套接字默认保持打开状态一段时间;HTTP/1.0 套接字默认关闭,除非它们具有 Keep-Alive 标头,在这种情况下,它们将保持打开状态一段时间。在本例中,我们选择关闭上述 TCP 套接字,并打开另一个连接到 192.168.0.122:8080(使用任何本地 IP/端口),并发送以下消息。一如既往,CRLF、UTF8 和末尾的空行,Content-Length 是正文的字节大小(未经编码);“UPnP 架构”文档详细介绍了 UPnP 中 SOAP 消息所需的所有内容。请注意,SOAP 操作指定了两次,一次在 SOAPAction 标头中(带有其 schema),另一次在 <u:GetprotocolInfo> 中(同样带有其 schema)。

POST /ConnectionManager/ctrl HTTP/1.1
Host: 192.168.0.122:8080
Content-Length: 299
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo"
 
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
    </u:GetProtocolInfo>
  </s:Body>
</s:Envelope>

同一 TCP 套接字上的 <<< 响应。如 UPnP 架构文档所述,每个可能的动作都有一些 IN 参数(这些参数将在出站消息中提供,但 GetProtocolInfo 没有任何参数)。还有 OUT 参数,它们在响应中提供。在本例中,有两个 out 参数 Source 和 Sink。它们的含义和格式记录在“ConnectionManager:1”文档的 2.5.2“协议概念”中。在这种情况下,设备告诉我们它能够播放哪些类型的媒体源。它可以发出 HTTP-GET 请求,通过任何网络,并接受 MIME 类型“audio/L16;rate=44100;channels=1”或“...channels=2”。还有大约二十种其他 MIME 类型(我截断了),包括 MP3 音频。

HTTP/1.1 200 OK
CONTENT-LENGTH: 4114
CONTENT-TYPE: text/xml; charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
EXT:
SERVER: KnOS/3.2 UPnP/1.0 DMP/3.5
 
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s = "http://schemas.xmlsoap.org/soap/envelope/" 
         s:encodingStyle = "http://schemas.xmlsoap.org/soap/encoding/">
 <s:Body>
   <u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
     <Source></Source>
     <Sink>http-get:*:audio/L16;rate=44100;channels=1:
        DLNA.ORG_PN=LPCM;DLNA.ORG_FLAGS=9D300000000000000000000000000000,
        http-get:*:audio/L16;rate=44100;channels=2:DLNA.ORG_PN=LPCM;
        DLNA.ORG_FLAGS=9D300000000000000000000000000000...[truncated]...</Sink>
   </u:GetProtocolInfoResponse>
 </s:Body>
</s:Envelope>

>>> 发送 SetAVTransportURI 的 TCP 消息到我们之前获取并使用的设备 AVTransport URL。我怎么知道要向哪个设备发送哪些操作,何时发送,以及按什么顺序?这一切都在“UPnP AV 架构”文档中有描述。在本例中,我使用 SetAVTransportURI 来告诉设备它将要从中请求音频的 URI。(希望音频的 MIME 类型是设备可以接受的!)我可以选择任何 URI。例如,我可以选择互联网上 MP3 的 URI,然后扬声器就会准备播放它。但我选择了“http://192.168.0.128:62890/dummy.l16”,这是一个我在我的控制台应用程序本身设置了一个迷你 HTTP 服务器的 IP+端口上定义的 URI。这样,扬声器将直接从我的应用程序获取音频。SetAVTransportURI 操作和响应的含义,以及它所有的 IN 和 OUT 参数,都在“AVTransport:1”文档中解释。

POST /AVTransport/ctrl HTTP/1.1 
Host: 192.168.0.122:8080
Content-Length: 449
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"
 
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 
             s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
      <InstanceID>0</InstanceID>
      <CurrentURI>http://192.168.0.128:62890/dummy.l16</CurrentURI>
      <CurrentURIMetaData></CurrentURIMetaData>
    </u:SetAVTransportURI>
  </s:Body> 
</s:Envelope>

同一 TCP 套接字上的 <<< 响应。此操作没有 OUT 参数,因此响应也不包含任何参数。

HTTP/1.1 200 OK 
CONTENT-LENGTH: 332 
CONTENT-TYPE: text/xml; charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
EXT:
SERVER: KnOS/3.2 UPnP/1.0 DMP/3.5
 
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 
            s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:SetAVTransportURIResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
    </u:SetAVTransportURIResponse>
  </s:Body> 
</s:Envelope>

>>> 发送 Play 的 TCP 消息到设备 AVTransport URL 执行“Play”SOAP 操作,与之前检索和使用的相同。同样,有可能重新使用与之前请求相同的 TCP 套接字,但我选择了每次创建一个新套接字(或使用更强大的库来抽象所有这些)。同样,此 Play 操作的含义和参数记录在“AVTransport:1”文档中。

POST /AVTransport/ctrl HTTP/1.1
Host: 192.168.0.122:8080
Content-Length: 329
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Play"
 
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 
          s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
      <InstanceID>0</InstanceID>
      <Speed>1</Speed>
    </u:Play>
  </s:Body>
</s:Envelope>

<<< 响应。响应 Play 消息后,设备开始从我们告诉它使用的 URI 请求数据并开始播放!我们很快就会讲到这一点。同时,它会进行确认。

HTTP/1.1 200 OK
CONTENT-LENGTH: 306
CONTENT-TYPE: text/xml; charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
EXT:
SERVER: KnOS/3.2 UPnP/1.0 DMP/3.5
 
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope
    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 
            s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:PlayResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
    </u:PlayResponse>
  </s:Body>
</s:Envelope>

<<< 使用 http-get 发出的请求来自扬声器。我们告诉扬声器使用“http://192.168.0.128:62890/dummy.l16”作为它们的媒体源。因此,它们遵循此协议,打开到此 IP 和端口的 TCP 连接,并对指定路径发出 GET 请求。在我的控制台应用程序中,我实现了一个 TCP 服务器,它在此 IP+端口上监听请求,因此我可以确切地看到扬声器发出了什么请求。这是一个标准的 HTTP 请求,因此它是 UTF8 编码,使用 CRLF,我们必须持续读取请求直到收到末尾的空行。看到这些头部很有意思!我们发现 Sony 使用 WinAmp 代码来实现其扬声器。

GET /dummy.l16 HTTP/1.1 
Host: 192.168.0.128:36743 
Icy-MetaData: 1
Connection: close
transferMode.dlna.org: Streaming
User-Agent: WinampMPEG/2.8
Accept: */*
X-AV-Client-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"

>>> 来自我们 TCP 服务器的响应发送给扬声器。这是对 HTTP-GET 消息的正常响应:它有头部(UTF8 编码,CRLF,以空行结束)。之后,我们发送响应的正文——也就是说,我们将原始 PCM 音频通过 TCP 套接字发送。我们自己在 Content-Header 中指定的 MIME 类型告诉扬声器它们正在接收什么;规范可在http://tools.ietf.org/html/rfc2586找到。这是 PCM 数据,两个通道:第一个 16 位样本来自左声道,然后是第一个 16 位样本来自右声道,然后是第二个 16 位样本来自左声道,依此类推。每个样本都是一个 16 位二进制补码数字。我认为它是“网络字节顺序”,最高有效字节在前,但我会混淆。我们可以像喜欢的那样,在这个 TCP 流上继续发送数据。我们可以以某种速率发送。有些客户端会尝试尽可能多地预先缓存。其他客户端只会接收最多几秒钟的数据(以应对随机的网络延迟)。

HTTP/1.1 200 OK
Content-Type: audio/L16;rate=44100;channels=2
 
....raw PCM data, in audio/L16 format......
....Trallalla..BOOM..deeay...............
.........
...
.
.

附加代码示例中还有其他有用的操作——GetTransportInfoGetPositionInfoStop。它们都遵循与此处相同的模式。

实现协议的服务器端

现在,我们必须实现该协议。我将“反向”解释实现——首先我将展示提供媒体的迷你 HTTP 服务器的实现,然后我将展示协议的其余部分。

服务器将监听传入的 TCP 请求。我们必须决定在哪个网络适配器上监听,即使用什么本地 IP 地址。我在这里硬编码了它,但在附加代码中,我们实际上是在 SSDP 发现阶段确定最佳本地 IP 地址。

Dim LocalIp = "192.168.0.128"

.NET 代码:接下来,用于设置接受连接的服务器 TCP 监听器的基础结构。这是使用 .NET 4.5 并在任何安装了 .NET45 的计算机上运行(例如,控制台应用程序)的一个版本。它使用 data.Start(1) 来只排队最多 1 个待处理请求。最后一行给出了我们将要告诉 AVTransport 从哪个 URI 进行播放的“dataLocation”。

data.Start(1)
Dim dataTask = Task.Run(
    Async Function()
        While True
            Using dataConnection = Await data.AcceptTcpClientAsync(),
                  dataStream = dataConnection.GetStream()
                Try
                    Await DoUpnpDataDialogAsync(dataStream, dataStream, progress)
                    Exit While
                Catch ex As IO.IOException When ex.HResult = -2146232800
                    ' Why would a DigitalMediaRenderer open a data connection only to
                    ' close it again immediately and reopen another one? I don't know,
                    ' but some do so we offer them a second opportunity.
                End Try
            End Using
        End While
    End Function)

Dim dataLocation = New Uri("http://" & data.Server.LocalEndPoint.ToString() & "/dummy.l16")

WinRT 代码:上面的代码不允许在应用商店应用中使用。您应该改用以下内容:

Using data As New Windows.Networking.Sockets.StreamSocketListener()
    AddHandler data.ConnectionReceived,
        Async Sub(sender As Windows.Networking.Sockets.StreamSocketListener, _
              args As Windows.Networking.Sockets.StreamSocketListenerConnectionReceivedEventArgs)
            Using args.Socket
                Using data_reader = IO.WindowsRuntimeStreamExtensions.AsStreamForRead(
                                       args.Socket.InputStream),
                  data_writer = IO.WindowsRuntimeStreamExtensions.AsStreamForWrite(
                                       args.Socket.OutputStream)
                  Try
                      Await DoUpnpDataDialogAsync(data_writer, data_reader, progress)
                  Catch ex As Exception When ex.HResult = -2147014842
                  End Try
              End Using
            End Using
        End Sub
 
    ' CAPABILITY: privateNetworkClientServer
    Await data.BindServiceNameAsync("")
 
    Dim dataLocation = New Uri("http://" & device.LocalUri.Host & _
          ":" & data.Information.LocalPort & "/stream.l16")
 
    ...
End Using

DoUpnpDataDialogAsync。上面的代码仅设置套接字。服务器的核心在此函数中:

Async Function DoUpnpDataDialogAsync(writer As IO.Stream, reader As IO.Stream,
                                     progress As IProgress(Of String)) As Task
    Dim request = ""
    Using sreader As New IO.StreamReader(reader, Text.Encoding.UTF8, False, 1024, True)
        While True
            Dim header = Await sreader.ReadLineAsync()
            If String.IsNullOrWhiteSpace(header) Then Exit While
            request &= header & vbCrLf
        End While
    End Using
 
    Dim response = "HTTP/1.1 200 OK" & vbCrLf &
                   "Content-Type: audio/L16;rate=44100;channels=2" & vbCrLf & vbCrLf
    Dim responsebuf = Text.Encoding.UTF8.GetBytes(response)
    Await writer.WriteAsync(responsebuf, 0, responsebuf.Length)
 
    Dim MaryHadALittleLamb = {247, 220, 196, 220, 247, 247, 247, 220, 220, 220, 247, 294, 294}
    Dim phase = 0.0
    Dim buf = New Byte(4096 * 4 - 1) {}
    For imusic = 0 To MaryHadALittleLamb.Length * 8 - 1
        Dim freq = If(imusic Mod 8 = 0, 0, MaryHadALittleLamb(imusic \ 8))
        For i = 0 To buf.Length \ 4 - 1
            phase += freq / 44100 * 2 * Math.PI
            Dim amplitude = If(freq = 0, 0, Math.Sin(phase) * (Short.MaxValue - 1))
            Dim bb = BitConverter.GetBytes(CShort(amplitude)) 
            buf(i * 4 + 0) = bb(1) ' left channel, MSB
            buf(i * 4 + 1) = bb(0) ' left channel, LSB
            buf(i * 4 + 2) = bb(1) ' right channel, MSB
            buf(i * 4 + 3) = bb(0) ' right channel, LSB
        Next
        Await writer.WriteAsync(buf, 0, buf.Length)
    Next
    Await writer.FlushAsync()
End Function

实现协议的客户端

在本文中,我将首先展示实现该协议的低级代码。之后,我将展示一些实现相同目的的更高级的包装器。

我这样写这篇文章是因为我相信,作为开发者,从根本上理解协议如何工作始终很重要。没有哪个网络抽象/包装器足够干净,可以让您忽略其底层。

WinRT SSDP 代码:我首先提供此代码的 WinRT 版本,因为 WinRT 网络 API 比 .NET 更干净。代码非常直接。它发送多播消息,并等待最多 1200 毫秒的响应。它收集收到的所有响应。(这里存在一个竞态条件,应该修复:如果响应同时到达,MessageReceived 处理程序可能会在不同线程上同时被调用)。

Dim remoteIp As New Windows.Networking.HostName("239.255.255.250"), remotePort = "1900"
Dim reqbuf = AsBuffer(CreateSsdpRequest(remoteIp.RawName() & ":" & remotePort)).AsBuffer()
Dim locs As New HashSet(Of Tuple(Of Uri, Uri))
 
Using socket As New Windows.Networking.Sockets.DatagramSocket()
    AddHandler socket.MessageReceived,
        Sub(sender, e)
            If e.LocalAddress.IPInformation.NetworkAdapter.IanaInterfaceType = 24 Then Return 
            ' don't show anything from the loopback adapter
            Using reader = e.GetDataReader()
                Dim responsebuf = New Byte(CInt(reader.UnconsumedBufferLength - 1)) {}
                reader.ReadBytes(responsebuf)
                Dim location = ParseSsdpResponse(responsebuf, responsebuf.Length)
                locs.Add(Tuple.Create(location, New Uri("http://" & e.LocalAddress.RawName)))
            End Using
        End Sub
 
    ' CAPABILITY: PrivateNetworks
    Await socket.BindEndpointAsync(Nothing, "")
    socket.Control.OutboundUnicastHopLimit = 1
    socket.JoinMulticastGroup(remoteIp)
    ' There's no WinRT equivalent of ReuseAddress, but it seems not to be needed

    Using stream = Await socket.GetOutputStreamAsync(remoteIp, remotePort)
        Await stream.WriteAsync(reqbuf)
    End Using
    Await Task.Delay(1200)
End Using

.NET SSDP 代码:此代码比 WinRT 等效代码稍微丑陋一些。据我所知,它必须显式调用它正在监听的网络接口……

Dim remoteEp = New Net.IPEndPoint(Net.IPAddress.Parse("239.255.255.250"), 1900)
Dim locs As New HashSet(Of Tuple(Of Uri, Uri))
 
Dim localIps =
     From network In Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()
     Where network.OperationalStatus = Net.NetworkInformation.OperationalStatus.Up
     Where network.NetworkInterfaceType <> Net.NetworkInformation.NetworkInterfaceType.Loopback
     Let localAddr = (From uaddr In network.GetIPProperties.UnicastAddresses _
         Where uaddr.Address.AddressFamily = Net.Sockets.AddressFamily.InterNetwork).FirstOrDefault
     Where Not localAddr Is Nothing
     Select localAddr.Address
 
For Each localIp In localIps
    Using socket As New Net.Sockets.Socket(Net.Sockets.AddressFamily.InterNetwork,
                                           Net.Sockets.SocketType.Dgram,
                                           Net.Sockets.ProtocolType.Udp)
        socket.SetSocketOption(Net.Sockets.SocketOptionLevel.Socket,
                               Net.Sockets.SocketOptionName.ReuseAddress, True)
        socket.SetSocketOption(Net.Sockets.SocketOptionLevel.IP,
                               Net.Sockets.SocketOptionName.AddMembership,
                               New Net.Sockets.MulticastOption(remoteEp.Address))
        socket.SetSocketOption(Net.Sockets.SocketOptionLevel.IP,
                               Net.Sockets.SocketOptionName.MulticastTimeToLive, 1)
 
        socket.Bind(New Net.IPEndPoint(localIp, 0))
        Dim receiverTask = Task.Run(
            Sub()
                Dim responsebuf = New Byte(51200) {}
                Do
                    Try
                        Dim ep As Net.EndPoint = New Net.IPEndPoint(Net.IPAddress.Any, 0)
                        Dim len = socket.ReceiveFrom(responsebuf, ep)
                        Dim location = ParseSsdpResponse(responsebuf, len)
                        locs.Add(Tuple.Create(location, New Uri("http://" & localIp.ToString)))
                    Catch ex As Net.Sockets.SocketException When ex.ErrorCode = 10004
                        Return ' WSACancelBlockingCall, when the socket is closed
                    End Try
                Loop
            End Sub)
 
        Dim request = CreateSsdpRequest(remoteEp.ToString())
        socket.SendTo(request, remoteEp)
        Await Task.Delay(1200)
 
        socket.Close()
        Await receiverTask
    End Using
Next

SSDP 数据包的辅助函数:一个函数用于构造 SSDP 请求数据包,一个函数用于解析响应并提取出那个关键的 LOCATION: 头部。

Function CreateSsdpRequest(authority As String) As Byte()
    Dim request = "M-SEARCH * HTTP/1.1" & vbCrLf &
                  "HOST: " & authority & vbCrLf &
                  "ST:urn:schemas-upnp-org:device:MediaRenderer:1" & vbCrLf &
                  "MAN: ""ssdp:discover""" & vbCrLf &
                  "MX: 1" & vbCrLf &
                  "" & vbCrLf
    Return Text.Encoding.UTF8.GetBytes(request)
End Function
 
Function ParseSsdpResponse(responsebuf As Byte(), len As Integer) As Uri
    Dim response = Text.Encoding.UTF8.GetString(responsebuf, 0, len)
    Return (From line In response.Split({vbCr(0), vbLf(0)})
            Where line.ToLowerInvariant().StartsWith("location:")
            Select New Uri(line.Substring(9).Trim())).FirstOrDefault
End Function

检索服务描述:到目前为止,我们有一个 HashSet,其中包含可以了解设备信息的 Location: URI,以及我们找到每个设备的 LocalIp。现在我们需要检索这些 URI,并解析它们以发现 ConnectionManagerAVTransport 控制 URI:

Imports <xmlns:ud="urn:schemas-upnp-org:device-1-0">
 
Dim desc_request = MakeRawGetRequest(deviceLocation)
Dim desc_response = Await GetXmlAsync(desc_request, progress)
Dim desc_friendlyName = desc_response.<ud:root>.<ud:device>.<ud:friendlyName>.Value
Dim desc_services = desc_response.<ud:root>.<ud:device>.<ud:serviceList>.<ud:service>
 
Dim connectionManagerUri =
     (From service In desc_services
      Where service.<ud:serviceType>.Value = "urn:schemas-upnp-org:service:ConnectionManager:1"
      Select New Uri(deviceLocation, service.<ud:controlURL>.Value)).FirstOrDefault
 
Dim avTransportUri =
     (From service In desc_services
      Where service.<ud:serviceType>.Value = "urn:schemas-upnp-org:service:AVTransport:1"
      Select New Uri(deviceLocation, service.<ud:controlURL>.Value)).FirstOrDefault

对 ConnectionManager 和 AVTransport 执行 SOAP 操作。协议的其余部分现在都非常顺利了。

Imports <xmlns:uc="urn:schemas-upnp-org:service:ConnectionManager:1">
Imports <xmlns:ut="urn:schemas-upnp-org:service:AVTransport:1">
 

Dim getprotocol_request = MakeSoapRequest(connectionManagerUri, <uc:GetProtocolInfo/>, {})
Dim getprotocol_response = Await GetSoapAsync(getprotocol_request, progress)
 
Dim mimeTypes = getprotocol_response.<Sink>.Value.Split({","c})
 
Dim seturi_request = MakeSoapRequest(avTransportUri, <ut:SetAVTransportURI/>,
         {"InstanceID", "0", "CurrentURI", _
          dataLocation.ToString(), "CurrentURIMetaData", ""})
Dim seturi_response = Await GetSoapAsync(seturi_request, progress)
 
Dim play_request = MakeRawSoapRequest(avTransportUri, <ut:Play/>,
        {"InstanceID", "0", "Speed", "1"})
Dim play_response = Await GetSoapAsync(play_request, progress)

用于发起各种 TCP 请求的辅助函数

Function MakeRawGetRequest(requestUri As Uri) As Tuple(Of Uri, Byte())
    Dim s = "GET " & requestUri.PathAndQuery & " HTTP/1.1" & vbCrLf &
            "Host: " & requestUri.Host & ":" & requestUri.Port & vbCrLf & vbCrLf
    Return Tuple.Create(requestUri, Text.Encoding.UTF8.GetBytes(s))
End Function
 
Function MakeRawSoapRequest(requestUri As Uri, soapAction As XElement,
                            args As String()) As Tuple(Of Uri, Byte())
    Dim soapSchema = soapAction.Name.NamespaceName
    Dim soapVerb = soapAction.Name.LocalName
    Dim argpairs As New List(Of Tuple(Of String, String))
    For i = 0 To args.Length - 1 Step 2
        argpairs.Add(Tuple.Create(args(i), args(i + 1)))
    Next
 
    Dim s = "POST " & requestUri.PathAndQuery & " HTTP/1.1" & vbCrLf &
            "Host: " & requestUri.Authority & vbCrLf &
            "Content-Length: ?" & vbCrLf &
            "Content-Type: text/xml; charset=""utf-8""" & vbCrLf &
            "SOAPAction: """ & soapSchema & "#" & soapVerb & """" & vbCrLf &
            "" & vbCrLf &
            "<?xml version=""1.0""?>" & vbCrLf &
            "<s:Envelope xmlns:s=""http://schemas.xmlsoap.org/soap/" & _ 
            "envelope/"" s:encodingStyle=""http://schemas.xmlsoap.org/soap/encoding/"">" & vbCrLf &
            "  <s:Body>" & vbCrLf &
            "    <u:" & soapVerb & " xmlns:u=""" & soapSchema & """>" & vbCrLf &
            String.Join(vbCrLf, (From arg In argpairs Select "      <" & _
            arg.Item1 & ">" & arg.Item2 & "</" & arg.Item1 & ">").Concat({""})) &
            "    </u:" & soapVerb & ">" & vbCrLf &
            "  </s:Body>" & vbCrLf &
            "</s:Envelope>" & vbCrLf
    '
    Dim len = Text.Encoding.UTF8.GetByteCount(s.Substring(s.IndexOf("<?xml")))
    s = s.Replace("Content-Length: ?", "Content-Length: " & len)
    Return tuple.Create(requestUri, Text.Encoding.UTF8.GetBytes(s))
End Function
 
Async Function GetXmlAsync(request As Tuple(Of Uri, Byte())) As Task(Of XDocument)
    Dim requestUri = request.Item1, requestBody = request.Item2
    Using socket As New Net.Sockets.TcpClient(requestUri.DnsSafeHost, requestUri.Port), stream = socket.GetStream()
        If Not progress Is Nothing Then progress.Report("--------->" & _
           vbCrLf & Text.Encoding.UTF8.GetString(requestBody))
        Await stream.WriteAsync(requestBody, 0, requestBody.Length)
        Await stream.FlushAsync()
        '
        Dim headers = "", body = ""
        Using sreader As New IO.StreamReader(stream, Text.Encoding.UTF8, False, 1024, True)
            Dim len = 0
            While True
                Dim header = Await sreader.ReadLineAsync()
                If String.IsNullOrWhiteSpace(header) Then Exit While
                If header.ToLower().StartsWith("content-length:") Then len = CInt(header.Substring(15).Trim())
                headers &= header & vbCrLf
            End While
            Dim buf = New Char(1024) {}
            While Text.Encoding.UTF8.GetByteCount(body) < len
                Dim red = Await sreader.ReadAsync(buf, 0, 1024)
                body &= New String(buf, 0, red)
            End While
        End Using
        '
        If Not headers.StartsWith("HTTP/1.1 200 OK") Then
            Throw New Net.Http.HttpRequestException(headers & vbCrLf & body)
        End If
        Return XDocument.Parse(body)
    End Using
End Function
 
Async Function GetSoapAsync(request As Tuple(Of Uri, Byte())) As Task(Of XElement)
    Dim requestLines = Text.Encoding.UTF8.GetString(request.Item2).Split({vbCrLf})
    Dim soapAction = (From s In requestLines Where _
      s.ToLower().StartsWith("soapaction:")).FirstOrDefault.Substring(11).Trim(" "c, """"c)
    Dim soapResponse As Xml.Linq.XName = "{" & soapAction.Replace("#", "}") & _
        "Response" ' e.g. {schema}actionResponse
    '
    Dim xml = Await GetXmlAsync(http, request)
    Dim body = xml.<soap:Envelope>.<soap:Body>.Elements(soapResponse).FirstOrDefault
    If body Is Nothing Then Throw New Net.Http.HttpRequestException("no soap body")
    Return body
End Function

更高级别的抽象:使用 HttpClient 和 XML 字面量

上面的代码助手使用原始字符串和字节构造了 HTTP 和 SOAP 请求。这显然是一个糟糕的解决方案——笨拙且容易出错。最好使用 HttpClient 和 VB 的 XML 字面量支持:

Function MakeGetRequest(requestUri As Uri) As Net.Http.HttpRequestMessage 
    Return New Net.Http.HttpRequestMessage(Net.Http.HttpMethod.Get, requestUri)
End Function
 
Function MakeSoapRequest(requestUri As Uri, soapAction As XElement,
                         args As String()) As Net.Http.HttpRequestMessage
    Dim m As New Net.Http.HttpRequestMessage(Net.Http.HttpMethod.Post, requestUri)
    m.Headers.Add("SOAPAction", """" & soapAction.Name.NamespaceName & _
                  "#" & soapAction.Name.LocalName & """")
 
    Dim c = <?xml version="1.0"?>
            <soap:Envelope soap:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
                <soap:Body>
                      <<%= soapAction.Name %>>
                          <%= Iterator Function()
                                  For i = 0 To args.Length - 1 Step 2
                                       Yield <<%= args(i) %>><%= args(i + 1) %></>
                                  Next
                              End Function() %>
                      </>
                </soap:Body>
            </soap:Envelope>
 
    m.Content = New Net.Http.StringContent(c.Declaration.ToString() & vbCrLf & _
                    c.ToString() & vbCrLf, Text.Encoding.UTF8, "text/xml")
    Return m
End Function
 
<Runtime.CompilerServices.Extension>
Async Function GetXmlAsync(http As Net.Http.HttpClient,
                           request As Net.Http.HttpRequestMessage) As Task(Of XDocument)
    Using response = Await http.SendAsync(request)
        response.EnsureSuccessStatusCode()
      
        ' workaround for bug in HttpClient:
        If Not response.Content.Headers.ContentType.CharSet Is Nothing Then
            response.Content.Headers.ContentType.CharSet = _
               response.Content.Headers.ContentType.CharSet.Trim(""""c)
 
        Dim body = Await response.Content.ReadAsStringAsync()
        Return XDocument.Parse(body) 
    End Using
End Function
 
<Runtime.CompilerServices.Extension>
Async Function GetSoapAsync(http As Net.Http.HttpClient,
                            request As Net.Http.HttpRequestMessage) As Task(Of XElement)
    Dim soapAction = request.Headers.GetValues("SOAPAction").FirstOrDefault.Trim(""""c) ' e.g. schema#action
    Dim soapResponse As Xml.Linq.XName = _
        "{" & soapAction.Replace("#", "}") & _
        "Response" ' e.g. {schema}actionResponse
    '
    Dim xml = Await GetXmlAsync(http, request)
    Dim body = xml.<soap:Envelope>.<soap:Body>.Elements(soapResponse).FirstOrDefault
    If body Is Nothing Then Throw New Net.Http.HttpRequestException("no soap body")
    Return body
End Function

更高级别的抽象:使用 WinRT 设备枚举

实际上,Windows 8 内置了 SSDP 发现功能。我们可以删除我们现有的 SSDP 代码,并用“WinRT 设备枚举”替换它。我在另一篇文章中更全面地描述了 win8 设备枚举:https://codeproject.org.cn/Articles/458550/Device-enumeration-in-Windows-8

WinRT 不按设备类型进行探测。回想一下,我们发送了一个 SSDP 探测,针对所有具有设备类型“urn:schemas-upnp-org:device:MediaRenderer:1”的设备。而 Windows 会探测所有设备——但不再允许我们按设备类型查询其结果

我们要做的是依赖于所有 MediaRenderer:1 设备都具有 RenderingControl 服务和 ConnectionManager 接口,以及(可选)AVTransport 接口的事实。所以我们将查找任何具有这些接口的设备。

这引发了一个有趣的哲学问题——是否存在某些设备其类型不是 MediaRenderer:1,但它们暴露了 ConnectionManager 和 AVTransport 接口?我们的代码应该能与它们一起工作吗?它们会做什么?我不知道!

WinRT 不按服务类型字符串进行探测。另外,我们自己的 SSDP 实现会查找类型为“urn:schemas-upnp-org:service:ConnectionManager:1”的服务。UPnP 协议就是这样查找 ConnectionManager 服务的。Well,WinRT 设备枚举将它们称为接口而不是服务。而且它不允许您按字符串查找它们。相反,您必须按 GUID 查找它们。我在下面写出了 GUID。我是如何发现 GUID 的?没有好的方法。您只需插入一些您知道具有正确服务类型的实际物理设备,测试 WinRT 设备枚举 API,并发现它们报告的 DeviceInterface::System.Devices.InterfaceClassGuid 属性的 GUID 是什么。

Dim RenderingControlInterfaceClass = New Guid("8660e926-ff3d-580c-959e-8b8af44d7cde")
Dim ConnectionManagerInterfaceClass = New Guid("ae9eb9c4-8819-51d8-879d-9a42ffb89d4e")
Dim AVTransportInterfaceClass = New Guid("4c38e836-6a2f-5949-9406-1788ea20d1d5")

WinRT 查找控制 URL 的方法。在我们自己的 SSDP 实现中,我们查找了 XML 描述的 service.<ud:controlURL> 元素。这为 ConnectionManagerAVTransport 服务提供了 control-URL。在 WinRT 中,controlURL 属性被隐藏了。我通过查找 Windows SDK 头文件中的 PKEYs,并通过实验确定了这个特定的 PKEY 指的是设备controlURL 属性:

Dim PKEY_PNPX_ServiceControlUrl = "{656A3BB3-ECC0-43FD-8477-4AE0404A96CD},16388"

用于 UPnP 的 WinRT 设备枚举代码。那么,这就是您在 WinRT 中用于发现 UPnP 设备控制 URL 的代码:

im RenderingControls =
     Await Windows.Devices.Enumeration.Pnp.PnpObject.FindAllAsync(
                Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceInterface,
                {"System.Devices.DeviceInstanceId", "System.Devices.InterfaceClassGuid", "System.Devices.ContainerId"},
                "System.Devices.InterfaceClassGuid:=""{" & RenderingControlInterfaceClass.ToString() & "}""")
 
For Each device In RenderingControls
    If Not device.Properties.ContainsKey("System.Devices.DeviceInstanceId") Then Continue For
    If Not device.Properties.ContainsKey("System.Devices.ContainerId") Then Continue For
    Dim id = CStr(device.Properties("System.Devices.DeviceInstanceId"))
    Dim containerId = CType(device.Properties("System.Devices.ContainerId"), Guid)
 
    Dim ConnectionManagerInterface =
         (Await Windows.Devices.Enumeration.Pnp.PnpObject.FindAllAsync(
                   Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceInterface,
                   {"System.Devices.DeviceInstanceId", _
                   "System.Devices.InterfaceClassGuid", PKEY_PNPX_ServiceControlUrl},
                   "System.Devices.DeviceInstanceId:=""" & id & _
                   """ AND System.Devices.InterfaceClassGuid:=""{" & _
                   ConnectionManagerInterfaceClass.ToString() & "}""")).FirstOrDefault
 
    If ConnectionManagerInterface Is Nothing Then Continue For
    If Not ConnectionManagerInterface.Properties.ContainsKey(PKEY_PNPX_ServiceControlUrl) Then Continue For
    Dim connectionManagerUrl = New Uri(CStr(ConnectionManagerInterface.Properties(PKEY_PNPX_ServiceControlUrl)))
 

    Dim AVTransportInterface =
         (Await Windows.Devices.Enumeration.Pnp.PnpObject.FindAllAsync(
                    Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceInterface,
                    {"System.Devices.DeviceInstanceId", _
                    "System.Devices.InterfaceClassGuid", PKEY_PNPX_ServiceControlUrl},
                    "System.Devices.DeviceInstanceId:=""" & id & _
                    """ AND System.Devices.InterfaceClassGuid:=""{" & _
                    AVTransportInterfaceClass.ToString() & "}""")).FirstOrDefault
 
    If Not AVTransportInterface Is Nothing AndAlso Not _
           AVTransportInterface.Properties.ContainsKey(PKEY_PNPX_ServiceControlUrl) Then
               AVTransportInterface = Nothing
    Dim avTransportUrl = If(AVTransportInterface Is Nothing, Nothing, _
        New Uri(CStr(AVTransportInterface.Properties(PKEY_PNPX_ServiceControlUrl))))
   
 
    Dim Container = Await Windows.Devices.Enumeration.Pnp.PnpObject.CreateFromIdAsync(
                            Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceContainer,
                            containerId.ToString(),
                            {"System.Devices.Connected", "System.Devices.FriendlyName"})
 
    If Container Is Nothing Then Continue For
    If Not Container.Properties.ContainsKey("System.Devices.Connected") Then Continue For
    If Not Container.Properties.ContainsKey("System.Devices.FriendlyName") Then Continue For
    Dim connected = CBool(Container.Properties("System.Devices.Connected"))
    Dim friendlyName = CStr(Container.Properties("System.Devices.FriendlyName"))
    If Not connected Then Continue For
 
    ...
Next

WinRT 代码用于发现已发现设备的本地 IP。还有最后一个任务。我们需要知道本地 IP,即设备将通过其连接到我们的 IP,以便我们能够构建正确的 URL 以供设备从中获取。在我们手动 SSDP 代码中,我们之所以知道,是因为我们知道从设备接收到 UDP 数据包的 IP 地址。但在 WinRT 设备枚举代码中,我们需要一个替代方法。

Try
    Dim localUri As Uri
    Using c As New Net.Sockets.TcpClient(connectionManagerUrl.DnsSafeHost,
                                         connectionManagerUrl.Port)
        Dim addr = CType(c.Client.LocalEndPoint, Net.IPEndPoint).Address
        Dim localHost = addr.ToString()
        If addr.AddressFamily = Net.Sockets.AddressFamily.InterNetworkV6 Then
            localHost = "[" & localHost & "]"
        End If
        localUri = New Uri("http://" & localHost)
    End Using
Catch ex  As Net.Sockets.SocketException
    ' oh well, I suppose we can't connect - sometimes the "connected" flag is incorrect
End Try

关于代码的说明

这些代码都不健壮。我试图编写最少量的代码来解释所涉及的 API。其目的是您应该阅读本文档来学习 API,然后在您自己的应用程序基础设施中使用它们。

免责声明:尽管我在微软从事 VB/C# 语言团队的工作,但本文档仅是基于公开信息和实验的个人业余努力——它不属于我的专业领域,已在我的业余时间撰写,微软和我本人均不对其准确性作任何声明……

除了在此代码中广泛使用“Await”和“Async”之外。我曾是微软 VB/C# 语言团队的一员,他们设计了这些关键字,并且我实现了它们在 VB 和 C# 编译器中的很大一部分。我为它们感到非常自豪,很高兴看到它们使实现这些协议变得如此容易,并且此代码以微软推荐的最佳实践方式使用了它们!

© . All rights reserved.