zoukankan      html  css  js  c++  java
  • WCF简单教程

    WCF是DotNet体系中很重要的一项技术,但是组内很多组员通过书籍自学的时候 感觉涉及面太广、配置文件太复杂,新名词太多、抓不到头绪,有感于此,决定进行一次组内技术培训,顺便把培训讲义整理到blog上来。不求大而全,而是要 让初学者快速入门,因此想入实例入手,并刻意隐藏一些初期用不到的内容,以降低入门门槛。有任何错误欢迎指正。

    注:本系列文章基于.Net Framework 3.5,以教程的最后会归纳一下到了4.0中有哪些差异。

    ----------------------- 分隔线 -----------------------

    第一篇:入门,构建第一个WCF程序

    1、服务端

    建立一个控制台应用程序作为Server,新建一个接口IData作为服务契约。这个契约接口一会儿也要放到Client端,这样双方才能遵循相同的标准。别忘了添加对System.ServiceModel的引用。

    1. using System;

    2. using System.ServiceModel;

    3. using System.Text;

    4. namespace Server

    5. {

    6.    /// <summary>

    7.    /// 用ServiceContract来标记此接口是WCF的服务契约,可以像WebService一样指定一个Namespace,如果不指定,就是默认的http://tempuri.org

    8.    /// </summary>

    9.    [ServiceContract(Namespace="WCF.Demo")]

    10.    public interface IData

    11.    {

    12.        /// <summary>

    13.        /// 用OperationContract来标记此方法是操作契约

    14.        /// </summary>

    15.        [OperationContract]

    16.        string SayHello(string userName);

    17.    }

    18. }


    针对这个接口建立实现类,这个类才是真正干活的,工作在服务端,不出现在客户端:

    1. using System;

    2. using System.Text;

    3. namespace Server

    4. {

    5.    /// <summary>

    6.    /// 实现IData接口,此处不需要写契约标记

    7.    /// </summary>

    8.    public class DataProvider : IData

    9.    {

    10.        public string SayHello(string userName)

    11.        {

    12.            return string.Format("Hello {0}.", userName);

    13.        }

    14.    }

    15. }


    为工程添加一个App.config文件,这里面要定义与服务发布相关的参数。WCF中常见的做法是用代码写服务逻辑,但是用配置文件来定义服务发布方式,这样做的好处是松散耦合。

    1. <?xmlversion="1.0"encoding="utf-8"?>

    2. <configuration>

    3. <system.serviceModel>

    4.  <!-- 看到services节,就表明这是在定义服务相关的内容 -->

    5.  <services>

    6.  <!-- 定义一个服务,name是契约实现类的全名 -->

    7.    <servicename="Server.DataProvider">

    8.    <!-- 既然要对外提供服务,就要有服务地址,此处定义为 http://localhost:8080/wcf,需要注意,地址总是带着类型标头的 -->

    9.      <host>

    10.        <baseAddresses>

    11.          <addbaseAddress="http://localhost:8080/wcf"/>

    12.        </baseAddresses>

    13.      </host>

    14.      <!-- 定义一下终节点,address一般为空,如果不为空,最终服务地址就是在baseAddress的基础上加上这个address,binding指定为basicHttpBinding,这是最基础的基于http的绑定方式,contract标明这是为哪个契约服务 -->

    15.      <endpointaddress=""binding="basicHttpBinding"contract="Server.IData"/>

    16.    </service>

    17.  </services>

    18. </system.serviceModel>

    19. </configuration>


    万事具备,只剩最后一步了,将服务发布出去:

    1. using System;

    2. using System.ServiceModel;

    3. namespace Server

    4. {

    5.    class Program

    6.    {

    7.        static void Main(string[] args)

    8.        {

    9.            //定义一个ServiceHost,注意参数中要使用契约实现类而不是接口

    10.            using(ServiceHost host = new ServiceHost(typeof(Server.DataProvider)))

    11.            {

    12.                host.Open();

    13.                Console.WriteLine("Service Running ...");

    14.                Console.ReadKey();

    15.                host.Close();

    16.            }

    17.        }

    18.    }

    19. }

    有人可能会问服务发布到哪去了?没指定地址呀?这是一个初学者容易搞不明白的地方。

    是 的,此时App.config中的定义就发挥作用了,由于ServiceHost中指定对Server.DataProvider类服务,而 App.config中定义了name="Server.DataProvider"的service,其下有endpoint,定义了绑定方式是 basicHttpBinding,而http方式的baseAddress只有一个,就是 http://localhost:8080/wcf。

    编译运行,屏幕显示Service Running ... 就是正常跑起来了,此时如果用命令行 netstat -ano | findstr "8080" 看一下,应该有如下输出:

    1. TCP    0.0.0.0:8080         0.0.0.0:0          LISTENING      4

    2. TCP    [::]:8080            [::]:0             LISTENING      4

    表示我们的程序已经在TCP 8080端口开始监听了。值得注意的是PID是4,这是系统进程而不是我们自己的进程,这说明WCF程序对外提供HTTP服务时,是借用了系统功能(http.sys)。

    此 时如果我们用浏览器访问一下 http://localhost:8080/wcf,不报错,但是会提示“当前已禁用此服务的元数据发布”,这是由于默认不允许以http get方式获取服务的WSDL,我们不用管它,不影响后面的使用,以后的章节中我们再来看这个问题。


    2、客户端

    再建立一个控制台应用程序作为Client,把Server中的接口IData拷过来,因为这是服务契约。

    为工程添加一个App.config文件,这里面要定义客户端访问的相关参数,这里我去掉了一些用不上的参数,以保持配置文件简单,防止各位看晕了头。

    1. <?xmlversion="1.0"encoding="utf-8"?>

    2. <configuration>

    3.  <system.serviceModel>

    4.    <!-- 看到client,就表明是客户端设置 -->

    5.    <client>

    6.      <!-- 定义访问时的终节点,name也是随意取的,注意address是Server端发布时指定的baseAddress+endpoint的address,binding也要对应,contract就更不用说了,由于之前把IData.cs拷过来的时候没有修改命名空间,所以还是Server.IData -->

    7.      <endpointname="DataService"address="http://localhost:8080/wcf"binding="basicHttpBinding"contract="Server.IData"/>

    8.    </client>

    9.  </system.serviceModel>

    10. </configuration>


    然后写代码,来调用Server端发布的SayHello方法:

    1. using System;

    2. using System.ServiceModel;

    3. using System.ServiceModel.Channels;

    4. namespace Client

    5. {

    6.    class Program

    7.    {

    8.        static void Main(string[] args)

    9.        {

    10.            //客户端访问有多种方式,此处只显示一种

    11.            //利用ChannelFactory的CreateChannel方法创建一个IData的代理对象,其中参数“DataService”就是刚才在App.config中定义的endpoint的名称

    12.            var proxy = new ChannelFactory<Server.IData>("DataService").CreateChannel();

    13. //调用SayHello方法

    14.            Console.WriteLine(proxy.SayHello("WCF"));

    15. //用完后一定要关闭,因为服务端有最大连接数,不关闭会在一定时间内一直占着有效连接

    16.            ((IChannel)proxy).Close();

    17.        }

    18.    }

    19. }

    编译运行,屏幕应能正常打印出“Hello WCF.”。第一个入门demo就搞定了,应该还是比较简单的。只是App.config的配置有些复杂,后面我们会看到,其实也可以不要配置,直接用代码搞定,不过从松散耦合的角度讲不建议这么做。

    第二篇:聊聊binding

    上一篇构建的WCF程序,binding指定的是basicHttpBinding,这是最基础的通讯方式,基于http,不加密,抓一下包的话,应该是这样的:

    1. 发送包: 
    2. POST /wcf HTTP/1.1 
    3. Content-Type: text/xml; charset=utf-8 
    4. SOAPAction: "WCF.Demo/IData/SayHello" 
    5. Host: 127.0.0.1:8080 
    6. Content-Length: 156 
    7. Expect: 100-continue 
    8. Connection: Keep-Alive 
    9.  
    10. <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><SayHello xmlns="WCF.Demo"><userName>WCF</userName></SayHello></s:Body></s:Envelope> 
    11.  
    12. -------------------------
    13. 应答包: 
    14. HTTP/1.1 100 Continue 
    15.  
    16. HTTP/1.1 200 OK 
    17. Content-Length: 191 
    18. Content-Type: text/xml; charset=utf-8 
    19. Server: Microsoft-HTTPAPI/2.0 
    20. Date: Mon, 05 Mar 2012 08:45:31 GMT 
    21.  
    22. <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><SayHelloResponse xmlns="WCF.Demo"><SayHelloResult>Hello WCF.</SayHelloResult></SayHelloResponse></s:Body></s:Envelope> 

    就是SOAP,和WebService是一样的。 basicHttpBinding的优势在于通用,又基于http,所以在跨语言的开发中或者是穿透复杂网络环境时占优势,但是效率比较低,因为SOAP 的序列化和反序列化比较耗时,传输的数据量也比较大,而且是明文,安全性差。

    WCF的binding有很多种,包括:

    basicHttpBinding(常用) 符合WSBasicProfile 1.1标准,以提供最大的互操作性
    wsHttpBinding(常用)  符合WS-*协议的HTTP通讯,加密 
    wsDualHttpBinding  双工http通信,初始信息的接收者不会直接响应发送者,而是可以在一定时间之内传送任意次的响应 
    wsFederationBinding  http通信,对服务资源的访问可以根据一个显式确定的凭据提供程序发出的凭据加以控制 
    netTcpBinding(常用)  提供网络里WCF软件实体之间的安全可靠高性能的通信 
    netNamedPipeBinding  同一台机器上的WCF软件实体之间的安全可靠高性能的通信 
    netMsmqBinding  WCF软件实体与其它软件实体间通过MSMQ通信 
    msmqIntegrationBinding 软件实体与其它软件实体间通过MSMQ通信 
    netPeerTcpBinding  WCF软件实体间通过Windows对等网络技术通信 


    内容很多,我们挑wsHttpBinding和netTcpBinding继续研究一下,这两个最有代表性。在上一篇demo的基础上修改一下。

    1、服务端

    服务端的功能逻辑没有任何变化,只是调整绑定方式,所以只调整App.config:

    1. <?xml version="1.0" encoding="utf-8" ?>  
    2. <configuration>  
    3.   <system.serviceModel>  
    4.     <services>  
    5.       <service name="Server.DataService">  
    6.         <host>  
    7.           <baseAddresses>  
    8.             <add baseAddress="http://localhost:8080/wcf" /> 
    9.             <!-- 此处增加了个baseAddress,注意标头指定的是net.tcp,另外端口不能被占用 --> 
    10.             <add baseAddress="net.tcp://localhost:8081/wcf" /> 
    11.           </baseAddresses>  
    12.         </host>  
    13. <!-- 此处调整了绑定使用wsHttpBinding方式 --> 
    14.         <endpoint address="" binding="wsHttpBinding" contract="Server.IData" /> 
    15.         <!-- 此处增加了一个endpoint,使用netTcpBinding方式,服务契约同样是IData --> 
    16.         <endpoint address="" binding="netTcpBinding" contract="Server.IData" /> 
    17.       </service>  
    18.     </services>  
    19.   </system.serviceModel>  
    20. </configuration>  

    与之前相比,增加了一个baseAddress和一个endpoint,另外调 整了之前endpoint的绑定方式。现在针对同一个服务契约,有两个endpoint了,但是它们不会冲突,因为两者的网络协议不同,所以 wsHttpBinding的会使用http://localhost:8080/wcf的地址,而netTcpBinding的会使用net.tcp: //localhost:8081/wcf的地址。

    如果同时定义了basicHttpBinding和wsHttpBinding呢?那么它们的address一定不能相同,因为baseAddress已经相同了,adress还一样,就无法访问了。

    编译运行一下,然后命令行“netstat -ano”看一下,应该能看到8080和8081都开始监听了。

    1. TCP    0.0.0.0:8080        0.0.0.0:0          LISTENING       4 
    2. TCP    0.0.0.0:8081        0.0.0.0:0          LISTENING       692 
    3. TCP    [::]:8080           [::]:0             LISTENING       4 
    4. TCP    [::]:8081           [::]:0             LISTENING       692 

    TCP 8081端口的PID显示是我们的服务程序在对外监听。


    2、客户端

    由于服务端修改了绑定方式,客户端必须要与之匹配,先修改App.config文件:

    1. <?xml version="1.0" encoding="utf-8" ?>  
    2. <configuration>  
    3.   <system.serviceModel>  
    4.     <client>  
    5.       <!-- 此处调整了binding为wsHttpBinding --> 
    6.       <endpoint name="httpDataService" address="http://localhost:8080/wcf" binding="wsHttpBinding" contract="Server.IData" /> 
    7.       <!-- 此处新增了一个endpoint,指定使用netTcpBinding方式 --> 
    8.       <endpoint name="tcpDataService" address="net.tcp://localhost:8081/wcf" binding="netTcpBinding" contract="Server.IData" /> 
    9.     </client>  
    10.   </system.serviceModel>  
    11. </configuration>  

    需要指出的是,服务端开放了2种访问方式,客户端不一定也要写2个endpoint,这里是为了测试两种绑定。

    调用代码也要做个小修改:

    1. using System; 
    2. using System.ServiceModel; 
    3. using System.ServiceModel.Channels; 
    4.  
    5. namespace Client 
    6.     class Program 
    7.     { 
    8.         static void Main(string[] args) 
    9.         { 
    10.          //定义一个http方式的代理,配置使用httpDataService中的定义 
    11.             var httpProxy = new ChannelFactory<Server.IData>("httpDataService").CreateChannel(); 
    12.             Console.WriteLine(httpProxy.SayHello("WCF")); 
    13.             ((IChannel)httpProxy).Close(); 
    14.  
    15.          //定义一个tcp方式的代理,配置使用tcpDataService中的定义 
    16.             var tcpProxy = new ChannelFactory<Server.IData>("tcpDataService").CreateChannel(); 
    17.             Console.WriteLine(tcpProxy.SayHello("WCF")); 
    18.             ((IChannel)tcpProxy).Close(); 
    19.         } 
    20.     } 


    编译运行一下,应该能够输出两行 Hello WCF.。

    抓包看看:

    wsHttpBinding方式的客户端与服务端总共交换了60多个数据包,这是因为双方要先握手交换密钥,另外由于数据加了密,长度也变大了。这里只截第一次交互的数据看看:

    1. 发送包: 
    2. POST /wcf HTTP/1.1 
    3. Content-Type: application/soap+xml; charset=utf-8 
    4. Host: 127.0.0.1:8080 
    5. Content-Length: 1106 
    6. Expect: 100-continue 
    7. Connection: Keep-Alive 
    8.  
    9. <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing"><s:Header><a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action><a:MessageID>urn:uuid:144b8aeb-6ac1-46f5-8361-957425b827c8</a:MessageID><a:ReplyTo><a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address></a:ReplyTo><a:To s:mustUnderstand="1">http://192.168.90.81:8080/wcf</a:To></s:Header><s:Body><t:RequestSecurityToken Context="uuid-156d27d6-3db7-43ac-b488-12f9ae123861-1" xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust"><t:TokenType>http://schemas.xmlsoap.org/ws/2005/02/sc/sct</t:TokenType><t:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</t:RequestType><t:KeySize>256</t:KeySize><t:BinaryExchange ValueType="http://schemas.xmlsoap.org/ws/2005/02/trust/spnego" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">TlRMTVNTUAABAAAAt7IY4gkACQAxAAAACQAJACgAAAAGAbAdAAAAD1RJQU5ZVS1QQ1dPUktHUk9VUA==</t:BinaryExchange></t:RequestSecurityToken></s:Body></s:Envelope> 
    10.  
    11. --------------------- 
    12. 应答包: 
    13. HTTP/1.1 100 Continue 
    14.  
    15. HTTP/1.1 200 OK 
    16. Content-Length: 1044 
    17. Content-Type: application/soap+xml; charset=utf-8 
    18. Server: Microsoft-HTTPAPI/2.0 
    19. Date: Tue, 06 Mar 2012 01:46:24 GMT 
    20.  
    21. <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing"><s:Header><a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RSTR/Issue</a:Action><a:RelatesTo>urn:uuid:144b8aeb-6ac1-46f5-8361-957425b827c8</a:RelatesTo></s:Header><s:Body><t:RequestSecurityTokenResponse Context="uuid-156d27d6-3db7-43ac-b488-12f9ae123861-1" xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"><t:BinaryExchange ValueType="http://schemas.xmlsoap.org/ws/2005/02/trust/spnego" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">TlRMTVNTUAACAAAAEgASADgAAAA1wprik+hmjwEji3X4ciEAAAAAAGgAaABKAAAABgGwHQAAAA9UAEkAQQBOAFkAVQAtAFAAQwACABIAVABJAEEATgBZAFUALQBQAEMAAQASAFQASQBBAE4AWQBVAC0AUABDAAQAEgBUAGkAYQBuAFkAdQAtAFAAQwADABIAVABpAGEAbgBZAHUALQBQAEMABwAIAPm5CvA6+8wBAAAAAA==</t:BinaryExchange></t:RequestSecurityTokenResponse></s:Body></s:Envelope> 

    应该是双方交换了一个256位的密钥,反正所有的数据交互都不再是明文的了。

    再来看netTcpBinding的这次,还是只截一小段吧:

    1. 发送包: 
    2. 00000000  00 01 00 01 02 02 20 6E  65 74 2E 74 63 70 3A 2F   ...... n et.tcp:/  
    3. 00000010  2F 31 39 32 2E 31 36 38  2E 39 30 2E 38 31 3A 38   /192.168 .90.81:8  
    4. 00000020  30 38 31 2F 77 63 66 03  08 09 15 61 70 70 6C 69   081/wcf. ...appli  
    5. 00000030  63 61 74 69 6F 6E 2F 6E  65 67 6F 74 69 61 74 65   cation/n egotiate  
    6. 00000040  16 01 00 00 3A 4E 54 4C  4D 53 53 50 00 01 00 00   ....:NTL MSSP....  
    7. 00000050  00 B7 B2 18 E2 09 00 09  00 31 00 00 00 09 00 09   ........ .1..... 
    8.  
    9. ---------- 
    10. 应答包: 
    11. 00000000  0A 16 01 00 00 B2 4E 54  4C 4D 53 53 50 00 02 00   ......NT LMSSP...  
    12. 00000010  00 00 12 00 12 00 38 00  00 00 35 C2 9A E2 3A D5   ......8. ..5...:.  
    13. 00000020  19 64 33 D4 B9 7C F8 72  21 00 00 00 00 00 68 00   .d3..|.r !.....h.  
    14. 00000030  68 00 4A 00 00 00 06 01  B0 1D 00 00 00 0F 54 00   h.J..... ......T.  
    15. 00000040  49 00 41 00 4E 00 59 00  55 00 2D 00 50 00 43 00   I.A.N.Y. U.-.P.C.  
    16. 00000050  02 00 12 00 54 00 49 00  41 00 4E 00 59 00 55 00   ....T.I. A.N.Y.U.  

    从效率上讲,tcp方式要比http方式高得多,同时http方式,basicHttpBinding又要比wsHttpBinding高得多,在实际使用中,大家要看需求来决定使用哪种方式。

    OK,下一篇我们看看能不能不用写复杂的配置就发布WCF服务,也以此来加深一下对Host、Contract、Binding、BaseAddress的理解。

    一般情况下调用远程WebService通过代理类直接访问就可以,可是如果WebService是在https站点下,调用时就要分情况考虑了,整理了一下:

    1、客户端证书已正确安装

    指已经在客户端安装了客户端证书到证书存储区,且证书符合以下几个条件:

      ◆ 证书中定义的使用者与访问WebService时使用的域名一致;
      ◆ 证书未过期;
      ◆ 证书链在本机完整可信;

    关于证书链完整可信,是指本证书,以及向上追溯的各级颁发者,直至根证书颁发者,都被系统承认。

    此时直接调用即可,与访问http时没有区别,底层会自动处理SSL握手。如果有任何一项不符合要求,调用时就会产生“基础连接已经关闭:未能为 SSL/TLS 安全通道建立信任关系”的异常,此时要使用2中的方法。

    2、客户端证书未正确安装

    1. public void CallRemoteWebService()  
    2.     //指定证书验证回调方法,Lamda表达式,直接返回true,表示忽略所有错误  
    3.     ServicePointManager.ServerCertificateValidationCallback = (source, cert, chain, errors) => true
    4.   
    5.     //创建WebService的代理实例  
    6.     var proxy = new Proxy();  
    7.     //远程调用  
    8.     proxy.DoSomething();  
    9. }  

    关键在于要让证书验证时忽略所有错误。不用担心,忽略错误只是指不用判断证书有效性,并不会影响通信信道的加密过程。

    第三篇:试着去掉配置文件

    通过配置文件来设置Host、Endpoint、Binding等是WCF中推 荐的方法,这样可以使发布尽量灵活。其实配置文件中的值,最终还是要体现到代码中的,只不过这部分工作由底层帮你做了。我们今天来尝试去掉配置文件,用纯 代码实现发布过程,同时加深一下对层次关系的理解。

    1、服务端

    在上回的基础上删掉App.config吧,然后把Main方法修改一下:

    1. using System;  
    2. using System.ServiceModel;  
    3.   
    4. namespace Server  
    5. {  
    6.     class Program  
    7.     {  
    8.         static void Main(string[] args)  
    9.         {  
    10.             //定义两个基地址,一个用于http,一个用于tcp 
    11.             Uri httpAddress = new Uri("http://localhost:8080/wcf"); 
    12.             Uri tcpAddress = new Uri("net.tcp://localhost:8081/wcf"); 
    13.             //服务类型,注意同样是实现类的而不是契约接口的 
    14.             Type serviceType = typeof(Server.DataProvider); 
    15.  
    16.             //定义一个ServiceHost,与之前相比参数变了 
    17.             using(ServiceHost host = new ServiceHost(serviceType, new Uri[] { httpAddress, tcpAddress }))  
    18.             {  
    19.                 //定义一个basicHttpBinding,地址为空 
    20.                 Binding basicHttpBinding = new BasicHttpBinding(); 
    21.                 string address = ""
    22.                 //用上面定义的binding和address,创建endpoint 
    23.                 host.AddServiceEndpoint(typeof(Server.IData), basicHttpBinding, address); 
    24.  
    25.                 //再来一个netTcpBinding 
    26.                 Binding netTcpBinding = new NetTcpBinding(); 
    27.                 address = ""
    28.                 host.AddServiceEndpoint(typeof(Server.IData), netTcpBinding, address); 
    29.  
    30.                 //开始服务 
    31.                 host.Open();  
    32.                 Console.WriteLine("Service Running ...");  
    33.                 Console.ReadKey();  
    34.                 host.Close();  
    35.             }  
    36.         }  
    37.     }  

    如果我们把代码和之前的App.config对比着的地一下,就会发现元素是对应的。我们来整理一下目前为止出现的层级关系:

     ServiceHost
       ├ ServiceType       实现类的类型
       ├ Uri[]                   基地址,对应config中的<baseAddresses>
       └ ServiceEndpoint[]       服务终结点,对应config中的多个<endpoint>
            ├ ServiceContract    服务契约,对应config中<endpoint>的contract属性
            ├ Binding            绑定,对应config中<endpoint>的binding属性
            └ EndpointAddress    终结点地址,对应config中<endpoint>的address属性


    2、客户端

    同样可以删掉App.config了,代码改一下:

    1. using System;  
    2. using System.ServiceModel;  
    3. using System.ServiceModel.Channels;  
    4.   
    5. namespace Client  
    6. {  
    7.     class Program  
    8.     {  
    9.         static void Main(string[] args)  
    10.         {  
    11.             //定义绑定与服务地址 
    12.             Binding httpBinding = new BasicHttpBinding(); 
    13.             EndpointAddress httpAddr = new EndpointAddress("http://localhost:8080/wcf"); 
    14.             //利用ChannelFactory创建一个IData的代理对象,指定binding与address,而不用配置文件中的  
    15.             var proxy = new ChannelFactory<Server.IData>(httpBinding, httpAddr).CreateChannel();  
    16.             //调用SayHello方法并关闭连接 
    17.             Console.WriteLine(proxy.SayHello("WCF"));  
    18.             ((IChannel)proxy).Close();  
    19.  
    20.             //换TCP绑定试试 
    21.             Binding tcpBinding = new NetTcpBinding(); 
    22.             EndpointAddress tcpAddr = new EndpointAddress("net.tcp://localhost:8081/wcf"); 
    23.             var proxy2 = new ChannelFactory<Server.IData>(httpBinding, httpAddr).CreateChannel();  
    24.             Console.WriteLine(proxy2.SayHello("WCF"));  
    25.             ((IChannel)proxy2).Close();  
    26.     }  
    27. }  

    对照着上面,也来比对一下代码中现出的对象与App.config中的定义:

     ClientEndpoint        客户端终结点,对应config中的<endpoint>
       ├ ServiceContract  服务契约,对应config中<endpoint>的contract属性
       ├ Binding          绑定,对应config中<endpoint>的binding属性
       └ EndpointAddress  地址,对应config中<endpoint>的address属性



    一般情况下,还是建议利用App.config来做发布的相关设定,不要写死代码。但如果只能在程序运行时动态获取发布的相关参数,那App.config就不行了。

    OK,又前进了一点,下一篇会看看如何传递复杂对象。

    第四篇:用数据契约传递自定义数据对象

    之前的演示中,我们一直都是在用string类型做参数和返回值,实际项目中肯定会传递自定义的数据类型。与WebService不同,WCF想传递自定义数据,必须要将其定义为数据契约。看一个例子:

    1. using System; 
    2. using System.Runtime.Serialization;  //注意加上这个引用,并引用相应的dll 
    3.  
    4. namespace Server 
    5.     //用DataContract来标记本类是数据契约类 
    6.     [DataContract] 
    7.     public class UserEntity 
    8.     { 
    9.         //用DataMember来标识数据成员,没有此标识的属性不会作为数据契约的一部分 
    10.         [DataMember] 
    11.         public string Name { getset; } 
    12.         [DataMember] 
    13.         public int Age { getset; } 
    14.     } 

    这个契约需要在客户端和服务端都存在,然后它就可以作为参数或返回值,在双方互相传递了,具体例子就省略了。

    这里面有一点需要注意,数据契约与服务契约有一点小小的区别,数据契约要求在客户端和服务端必须保持完全一致的类名与命名空间,否则就无法传递数据,这与服务契约是不同的,服务契约放到客户端时允许换个命名空间。

    组内有同事遇到过类似的问题,直接把数据契约类copy了一份到客户端之后改了命名空间,然后就一直取不到数据。 这里也引出另一个话题,推荐把各种契约(不含实现类)单独封装成一个dll,双方均引用它,结构上清晰,也避免出错。

    第五篇:用IIS做Host

    之前几篇的Demo中,我们一直在用控制台程序做Server,今天换IIS来做Host,在Web Application中添加WCF的服务。

    其 实在Web Application中添加WCF服务是最简单的,“新建项”中有专用的“WCF服务”,扩展名为svc。比如我们创建 DataService.svc,Visual Studio会自己创建好DataService.svc、DataService.svc.cs、IDataService.cs共三个文件,并且自动 在Web.config中增加默认设置。

    从功能上看,IDataService.cs是服务契约,DataService.svc.cs是契约的实现类,DataService.svc没什么实际用处,里面也只有一行代码:

    1. <%@ ServiceHost Language="C#" Debug="true" Service="WebServer.DataService" CodeBehind="DataService.svc.cs" %>


    Web.config中的部分与之前略有不同,我们来分析一下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <system.serviceModel>
      <!-- 这个节点是新加的,后面会讨论一下 -->
      <behaviors>
        <serviceBehaviors>
          <behavior name="WebServer.DataServiceBehavior">
            <serviceMetadata httpGetEnabled="true" />
            <serviceDebug includeExceptionDetailInFaults="false" />
          </behavior>
        </serviceBehaviors>
      </behaviors>
      <services>
        <!-- 新增了behaviorConfiguration属性,值就是上面定义过的behavior的name,表示此service使用指定的behavior配置 -->
        <service behaviorConfiguration="WebServer.DataServiceBehavior" name="WebServer.DataService">
          <endpoint address="" binding="wsHttpBinding" contract="WebServer.IDataService" />
          <!-- 这个endpoint是新加的,后面会讨论一下 -->
          <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
        </service>
      </services>
    </system.serviceModel>


    与之前的App.config相比,有以下几点不同:

    1、<host>节点没有了

    host节点没有了,对应的baseAddress也没有了,这是理所当然的事,因为不需要,访问DataService.svc文件时的url本身就是一个地址了。

    2、新增加了一个<behaviors>节点

    此节点用于控制行为,在服务端只有<serviceBehaviors>子节点,下面的httpGetEnabled="true"表示允许用http的get方式获取服务的元数据信息。还记得第一篇中的例子吗?我们用浏览器访问时,得到一个“当前已禁用此服务的元数据发布”的提示,就是因为不允许以http的get方式获取服务元数据造成的,这个属性就是开启此功能。

    顺便提一下,用svcutil.exe生成客户端代理的话,对http类型的binding,必须要开放get方式访问元数据。

    3、新增加了一个endpoint

    这个endpoint比较特殊,它的binding是mexHttpBinding,服务契约是IMetadataExchange。这个endpoint是用于元数据发布的,它的功能实际上和刚才的httpGetEnabled="true"有些重复。

    我 们可以这样理解,当开启了httpGetEnabled时,用 http://...../DataService.svc?wsdl 就可以访问到元数据;如果没开启,但有这个endpoint,用 http://...../DataService.svc/mex 也可以访问到元数据;如果都没有,那对不起,不允许你获取元数据。(当然啦,如果你已经有契约了,不会影响调用的)

    多加一句,对tcp类型的binding,有一个对应的mexTcpBinding用于获取元数据,没有定义它,svcutil.exe就不能生成tcp类binding的代理类。

    在IIS中发布WCF服务,一般很少用http之外的binding,但不代表不能用,IIS是支持的,我们在IIS管理器中查看一下站点绑定,默认情况下能看到这样的设置:

    像netTcpBinding,就会利用IIS的net.tcp类型绑定,端口是808。

    第六篇:单向与双向通讯

    项目开发中我们时常会遇到需要异步调用的问题,有时忽略服务端的返回值,有时希望服务端在需要的时候回调,今天就来看看在WCF中如何实现。

    先看不需要服务端返回值的单向调用,老规矩,直接上代码,再解释。

    1、服务端

    契约接口中增加一个Sleep方法:

    1. using System;  
    2. using System.ServiceModel;  
    3. using System.Text;  
    4.   
    5. namespace Server  
    6. {  
    7.     [ServiceContract(Namespace="WCF.Demo")]  
    8.     public interface IData  
    9.     {  
    10.         [OperationContract]  
    11.         string SayHello(string userName);  
    12.  
    13.         /// <summary> 
    14.         /// IsOneWay = true 表明这是一个单向调用,注意返回值是void,因为既然是单向调用,客户端肯定不会等待接收返回值的 
    15.         /// </summary> 
    16.         [OperationContract(IsOneWay = true)] 
    17.         void Sleep(); 
    18.     }  


    对应的实现类中,我们来实现这个方法:

    1. using System;  
    2. using System.Text;  
    3.   
    4. namespace Server  
    5. {  
    6.     public class DataProvider : IData  
    7.     {  
    8.         public string SayHello(string userName)  
    9.         {  
    10.             return string.Format("Hello {0}.", userName);  
    11.         }  
    12.  
    13.         /// <summary> 
    14.         /// 实现Sleep方法,暂时不做任何事情,只是睡眠5秒 
    15.         /// </summary> 
    16.         public void Sleep() 
    17.         { 
    18.             Thread.Sleep(5000); 
    19.         } 
    20.     }  
    21. }  

    App.config就不再列出来了,里面用的是一个netTcpBinding的endpoint。


    2、客户端

    首先别忘了客户端的契约要与服务端保持一致,App.config也不列出来了,里面有对应的endpoint。主要是调用的代码:

    1. using System;  
    2. using System.ServiceModel;  
    3. using System.ServiceModel.Channels;  
    4.   
    5. namespace Client  
    6. {  
    7.     class Program  
    8.     {  
    9.         static void Main(string[] args)  
    10.         {  
    11.             var proxy = new ChannelFactory<Server.IData>("DataService").CreateChannel();  
    12.  
    13.             //先调用SayHello方法  
    14.             Console.WriteLine(proxy.SayHello("WCF"));  
    15.             //调用一下Sleep方法,按我们的设想,它应该是异步的,所以不会阻塞后面的调用 
    16.             proxy.Sleep(); 
    17.             //再调用一次SayHello方法  
    18.             Console.WriteLine(proxy.SayHello("WCF"));  
    19.             //关闭连接  
    20.             ((IChannel)proxy).Close();  
    21.     }  

    按我们的设想,两次SayHello调用之间应该没有延迟,因为Sleep是异步的嘛,编译运行一下,结果……  中间卡住了5秒,这是为什么呢?

    这其中涉及到一个并发模型的问题,默认情况下,WCF以单线程模型对外提供服务,也就是说,只能一个一个处理请求,即使是一个OneWay的单向调用,也只能等它处理完后才会接着处理后面的SayHello请求,所以会卡5秒。

    并发模式有以下三种,MSDN上的介绍有点复杂,我给简化一下:

    Single:单线程调用,请求只能一个一个处理;

    Reentrant:可重入的单线程调用,本质仍是单线程,处理回调时,回调请求会进入队列尾部排队;

    Multiple:多线程调用,请求是并发的响应的;


    调置服务并发模型是在契约的实现类上,我们为DataService类加一个Attribute:

    1. /// <summary> 
    2. /// 用ServiceBehavior为契约实现类标定行为属性,此处指定并发模型为ConcurrencyMode.Multiple,即并发访问 
    3. /// </summary> 
    4. [ServiceBehavior(ConcurrencyMode = ConcurrencyMode.Multiple)] 
    5. public class DataProvider : IData 
    6.     //略 


    这回再编译运行一下,连续打出了2行 Hello WCF,中间不再阻塞了。

    现在我们再来看看双向通讯的问题。双向通讯可以基于HTTP、TCP、 Named Pipe、MSMQ,但要注意,basicHttpBinding和wsHttpBinding不行,要换用wsDualHttpBinding,它会创 建两个连接来进行双向通讯。至于TCP,它天然就是双向通讯的。

    1、服务端

    服务契约要进行修改,增加关于回调的契约:

    1. using System; 
    2. using System.ServiceModel; 
    3. using System.ServiceModel.Description; 
    4.  
    5. namespace Server 
    6.     /// <summary> 
    7.     /// 增加了CallbackContract的标识,用于指明针对此服务契约的回调契约是IDataCallback 
    8.     /// </summary> 
    9.     [ServiceContract(Namespace = "WCF.Demo", CallbackContract = typeof(IDataCallback))] 
    10.     public interface IData 
    11.     { 
    12.         [OperationContract] 
    13.         string SayHello(string userName); 
    14.  
    15.         [OperationContract(IsOneWay = true)] 
    16.         void Sleep(); 
    17.     } 
    18.  
    19.     /// <summary> 
    20.     /// 定义服务回调契约,注意它没有契约标识,只是个一般接口 
    21.     /// </summary> 
    22.     public interface IDataCallback 
    23.     { 
    24.         /// <summary> 
    25.         /// 定义一个回调方法,由于回调不可能要求对方再响应,所以也标识成OneWay的调用,同样不需要有返回值 
    26.         /// </summary> 
    27.        [OperationContract(IsOneWay = true)] 
    28.        void SleepCallback(string text); 
    29.     } 


    对应的契约实现类要修改一下:

    1. using System; 
    2. using System.ServiceModel; 
    3. using System.ServiceModel.Description; 
    4. using System.Threading; 
    5. using System.Net; 
    6.  
    7. namespace Server 
    8.     [ServiceBehavior(ConcurrencyMode = ConcurrencyMode.Multiple)] 
    9.     public class DataProvider : IData 
    10.     { 
    11.         public string SayHello(string userName) 
    12.         { 
    13.             string.Format("Hello {0}.", userName); 
    14.         } 
    15.  
    16.         public void Sleep() 
    17.         { 
    18. //先睡5秒
    19.             Thread.Sleep(5000); 
    20.  
    21. //用OperationContext.Current来获取指定类型的回调对象 
    22.             var callback = OperationContext.Current.GetCallbackChannel<IDataCallback>(); 
    23. //回调SleepCallback方法,并传递参数 
    24.             callback.SleepCallback("睡醒了"); 
    25.         } 
    26.     } 


    2、客户端

    仍然提醒一下别忘了把新的服务契约更新到客户端。客户端的调用要调整一下:

    1. using System;  
    2. using System.ServiceModel;  
    3. using System.ServiceModel.Channels;  
    4.   
    5. namespace Client  
    6. {  
    7.     class Program  
    8.     {  
    9.         static void Main(string[] args)  
    10.         {  
    11.             //定义一个实现回调接口的类实例 
    12.             var context = new DataCallbackImp(); 
    13.             //创建代理的时候变了,要用DuplexChannelFactory,因为IData契约已经标识了有回调,所以必须要用支持双向通讯的ChannelFactory,传入刚才创建的回调实例 
    14.             var proxy = new DuplexChannelFactory<Server.IData>(context, "DataService").CreateChannel();  
    15.  
    16.             //调用Sleep 
    17.             proxy.Sleep(); 
    18.             //调用SayHello方法  
    19.             Console.WriteLine(proxy.SayHello("WCF"));  
    20.  
    21.             //等待按任意键,先不要关连接 
    22.             Console.ReadKey(); 
    23.             ((IChannel)proxy).Close();  
    24.     }  
    25.  
    26.     /// <summary> 
    27.     /// 实现回调接口中的类,图省事写到这里了 
    28.     /// </summary> 
    29.     class DataCallbackImp : Server.IDataCallback 
    30.     { 
    31.         /// <summary> 
    32.         /// 实现SleepCallback方法 
    33.         /// </summary> 
    34.         public void SleepCallback(string text) 
    35.         { 
    36.             Console.WriteLine("收到回调了:" + text); 
    37.         } 
    38.     } 


    编译运行,屏幕先显示一行“Hello WCF.”,过5秒后显示“收到回调了:睡醒了”。

    第七篇:并发模型与实例模型

    在以往使用WebService时,针对每一个请求,服务类总是并发响应的,并且对每个请求都生成新的实例。在WCF中,情况发生变化了,它允许服务发布者自定义并组合并发模型与实例模型。

    并发模型有三种:

    ConcurrencyMode

    Single:   单线程模型,可以理解为,针对一个客户端,只有一个线程负责响应;
    Reentrant:可重入的单线程模型,与Single的区别在于,对于OneWay/回调,它不会阻塞,而是把回调的线程放到队列尾部等着最后处理;
    Multiple: 多线程模型,可以理解为,针对一个客户端,也允许并发访问;

    实例模型也有三种:

    InstanceContextMode

    PerCall:   针对每次调用都生成新的服务实例;
    PerSession:针对一个会话生成一个服务实例;
    Single:    针对所有会话和所有调用共用同一个服务实例;

    组合起来是什么效果呢?我们来用示例代码验证一下。

    1、服务端

    服务契约,具体解释见实现类吧,只要注意一下Sleep方法定义成了IsOneWay=true:

    1. using System; 
    2. using System.ServiceModel; 
    3. using System.ServiceModel.Description; 
    4.  
    5. namespace Server 
    6.     [ServiceContract(Namespace = "WCF.Demo")] 
    7.     public interface IData 
    8.     { 
    9.         [OperationContract] 
    10.         int GetCounter(); 
    11.  
    12.         [OperationContract(IsOneWay = true)] 
    13.         void Sleep(); 
    14.     } 


    契约实现类:

    1. using System; 
    2. using System.ServiceModel; 
    3. using System.ServiceModel.Description; 
    4. using System.Threading; 
    5. using System.Net; 
    6.  
    7. namespace Server 
    8.     //Single并发模式 + PerCall实例模式,针对后面的测试要修改这两个值的组合 
    9.     [ServiceBehavior(ConcurrencyMode = ConcurrencyMode.Single, InstanceContextMode = InstanceContextMode.PerCall)] 
    10.     public class DataProvider : IData 
    11.     { 
    12.         //定义一个计数器,对每个新生成的服务实例,它都是0,我们通过它来判断是否新实例 
    13.         public int Counter { getset; } 
    14.  
    15.         //获取计数器,并自增计数器 
    16.         public int GetCounter() 
    17.         { 
    18.             return ++Counter; 
    19.         } 
    20.  
    21.         //睡眠2秒 
    22.         public void Sleep() 
    23.         { 
    24.             Thread.Sleep(2000); 
    25.         } 
    26.     } 

    App.config不列了,用的是netTcpBinding。


    2、客户端:

    别的不列了,只列一下调用代码:

    1. using System; 
    2. using System.ServiceModel; 
    3. using System.ServiceModel.Channels; 
    4. using System.Threading; 
    5.  
    6. namespace Client 
    7.     class Program 
    8.     { 
    9.         static void Main(string[] args) 
    10.         { 
    11. //启动3个线程并发访问 
    12.             for(int i = 0; i < 3; ++i) 
    13.             { 
    14.                 var thread = new Thread(() => 
    15.                 { 
    16.                     string name = Thread.CurrentThread.Name; 
    17.  
    18.                     var proxy = new ChannelFactory<Server.IData>("DataProvider").CreateChannel(); 
    19.  
    20. //先调用GetCounter方法,再调用Sleep方法,然后再调一次GetCounter方法
    21.                     Console.WriteLine(string.Format("{0}: {1}  {2}", name, proxy.GetCounter(), DateTime.Now.ToString("HH:mm:ss.fff")));
    22.   proxy.Sleep(); 
    23.                     Console.WriteLine(string.Format("{0}: {1}  {2}", name, proxy.GetCounter(), DateTime.Now.ToString("HH:mm:ss.fff"))); 
    24.                     ((IChannel)proxy).Close(); 
    25.                 }); 
    26.  
    27. //定义一下线程名,方便识别 
    28.                 thread.Name = "线程" + i; 
    29.                 thread.Start(); 
    30.             } 
    31.         } 
    32.     } 

    OK,开始验证:

    1、ConcurrencyMode.Single + InstanceContextMode.PerCall

    执行结果如下:

    1. 线程1: 1  15:56:05.262 
    2. 线程2: 1  15:56:05.262 
    3. 线程0: 1  15:56:05.263 
    4. 线程1: 1  15:56:07.263 
    5. 线程2: 1  15:56:07.263 
    6. 线程0: 1  15:56:07.264 

    首先,打印出的 Counter全是1,说明针对每次请求,服务端的契约实现类(DataProvider)都是新实例化的。其次,同一个线程的两次GetCounter 请求相隔了2秒,说明针对一个客户端的调用阻塞了。再次,三个线程几乎同时完成调用,说明它们之间并未互相阻塞。

    2、ConcurrencyMode.Single + InstanceContextMode.PerSession

    执行结果如下:

    1. 线程0: 1  16:02:46.173 
    2. 线程1: 1  16:02:46.173 
    3. 线程2: 1  16:02:46.173 
    4. 线程1: 2  16:02:48.174 
    5. 线程2: 2  16:02:48.174 
    6. 线程0: 2  16:02:48.174 

    与上面相比,区别在于同一个线程的Counter在第二次调用时变成2了,说明针对同一个客户端的两次调用使用的是同一个服务实例。

    3、ConcurrencyMode.Single + InstanceContextMode.Single

    执行结果如下:

    1. 线程1: 2  16:05:46.270 
    2. 线程0: 1  16:05:46.270 
    3. 线程2: 3  16:05:46.270 
    4. 线程1: 4  16:05:52.273 
    5. 线程0: 5  16:05:52.273 
    6. 线程2: 6  16:05:52.274 

    与上面相比,区别在于Counter一直在增长,这说明在服务端自始至终只有一个服务实例,它来响应所有的会话所有的请求。

    4、ConcurrencyMode.Reentrant + InstanceContextMode.PerCall

    执行结果如下:

    1. 线程1: 1  16:07:42.505 
    2. 线程2: 1  16:07:42.506 
    3. 线程2: 1  16:07:42.507 
    4. 线程1: 1  16:07:42.507 
    5. 线程0: 1  16:07:42.505 
    6. 线程0: 1  16:07:42.507 

    和1的区别在于两次GetCounter调用之间没有2秒的延迟,这是由于Reentrant模式下,回调被放入队列尾部再处理,不会阻塞后面的调用。并且针对同一客户端的每个请求都是不同的服务实例在处理,不会阻塞。

    5、ConcurrencyMode.Reentrant + InstanceContextMode.PerSession

    执行结果如下:

    1. 线程2: 1  16:27:44.699 
    2. 线程0: 1  16:27:44.700 
    3. 线程1: 1  16:27:44.699 
    4. 线程0: 2  16:27:46.700 
    5. 线程1: 2  16:27:46.700 
    6. 线程2: 2  16:27:46.700 

    与上面相比,区别在于又有了2秒的阻塞,这是由于针对一个客户端的多次请求,是 同一个服务实例在处理,虽然允许重入,但只有一个对象,执行顺序是:第一次GetCounter->Sleep(不阻塞)->Sleep回调 ->第二次GetCounter,所以表现上还是阻塞住了。

    6、ConcurrencyMode.Reentrant + InstanceContextMode.Single

    执行结果如下:

    1. 线程0: 1  16:34:01.417 
    2. 线程1: 3  16:34:01.418 
    3. 线程2: 2  16:34:01.417 
    4. 线程0: 4  16:34:05.420 
    5. 线程1: 5  16:34:07.420 
    6. 线程2: 6  16:34:07.420 

    自始至终只有一个服务实例,执行顺序应该是:线程0的 GetCounter->线程2的GetCounter->线程1的GetCounter->线程0的Sleep(不阻塞)-> 线程2的Sleep(不阻塞)->线程0的Sleep回调->线程1的Sleep(不阻塞)->线程0的 GetCounter->线程2的Sleep回调->线程1的Sleep回调->线程1的GetCounter->线程2的 GetCounter。挺晕的。

    7、ConcurrencyMode.Multiple + InstanceContextMode.PerCall

    执行结果如下:

    1. 线程2: 1  17:07:05.639 
    2. 线程1: 1  17:07:05.639 
    3. 线程0: 1  17:07:05.639 
    4. 线程2: 1  17:07:05.640 
    5. 线程1: 1  17:07:05.641 
    6. 线程0: 1  17:07:05.641 

    多次调用完全是并发的,每次调用的实例也是新创建的。

    8、ConcurrencyMode.Multiple + InstanceContextMode.PerSession

    执行结果如下:

    1. 线程1: 1  17:09:10.285 
    2. 线程0: 1  17:09:10.285 
    3. 线程1: 2  17:09:10.286 
    4. 线程0: 2  17:09:10.286 
    5. 线程2: 1  17:09:10.285 
    6. 线程2: 2  17:09:10.287 

    多次调用完全并发,但针对同一个会话,实例是相同的。

    9、ConcurrencyMode.Multiple + InstanceContextMode.Single

    执行结果如下:

    1. 线程1: 1  17:16:46.543 
    2. 线程0: 3  17:16:46.543 
    3. 线程2: 2  17:16:46.543 
    4. 线程1: 4  17:16:46.544 
    5. 线程0: 5  17:16:46.544 
    6. 线程2: 6  17:16:46.544 

    完全并发,Counter也一直增长,表明自始至终是同一个服务实例。

  • 相关阅读:
    javascript提升复习
    关于加解密的
    java动态代理汇总
    ActiveMQ 使用
    16年上半年小结,下半年计划
    多线程之ReentrantReadWriteLock
    xml转换之
    2015-03 月份学习总结 分类: 学习总结 2015-04-01 20:25 87人阅读 评论(0) 收藏
    2015-03 月份学习总结 分类: 学习总结 2015-04-01 20:25 88人阅读 评论(0) 收藏
    IBM Rational AppScan 无法记录登录序列 分类: 数据安全 2015-03-18 16:46 157人阅读 评论(0) 收藏
  • 原文地址:https://www.cnblogs.com/zeroone/p/3331674.html
Copyright © 2011-2022 走看看