[HTTP]Think in Pushlet

Think in Pushlet
作者:cleverpig
image

原文地址:http://www.matrix.org.cn/resource/article/2007-01-16/bcc2c490-a502-11db-8440-755941c7293d.html
介绍
        server端向浏览器client发送通知这种通讯模式在J2EE应用中很常见,通常使用采用RMICORBA或 者自定义TCP/IP信息的applet来实现。这些技术往往由于复杂而产生诸多不利之处:技术难以实现、存在防火墙限制(因为需要打开非HTTP的通讯 端口)、需要额外的server开发和维护。并且除了刷新整个页面或者完全采用applet展示内容之外,很难找到别的方法将client端applet 的状态和浏览器的页面内容集成在一起。
        Pushlet是一种comet实 现:在Servlet机制下,数据从server端的Java对象直接推送(push)到(动态)HTML页面,而无需任何Java applet或者插件的帮助。它使server端可以周期性地更新client的web页面,这与传统的request/response方式相悖。浏览 器client为兼容JavaScript1.4版本以上的浏览器(如Internet Explorer、FireFox),并使用JavaScript/Dynamic HTML特性。而低层实现使用一个servlet通过Http连接到JavaScript所在的浏览器,并将数据推送到后者。有关JavaScript版 本的知识请参看Mozilla开发中心提供的《JavaScript核心参考》Stephen Chapman编写的《What Version of Javascript》
         这种机制是轻量级的,它使用server端的servlet连接管理、线程工具、javax.servlet API,并通过标准Java特性中Object的wait()和notify()实现的生产者/消费者机制。原则上,Pushlet框架能够运行在任何支 持servlet的server上、防火墙的后面。当在client中使用JavaScript/DHTML时,Pushlet提供了通过脚本快速建立应 用、使用HTML/CSS特性集成和布局新内容的便利方法。
动机
        目前越来越多的servlet和JSP用来部署web,于是便出现了在页面已经装载完毕后由于server端某些对象的状态变化而产生对client浏览器进行通知和同步的需要。
        这些状态变化的原因很复杂:可能由于用户通过访问servlet或者修改数据库记录、更新EJB造成,或是在多用户应用(比如聊天室和共享白板)中的事件导致数据状态变化。这些类型的应用常常使用一种分布式的MVC模板:模式层位于server上(可能缓存在client中),控制层和视图层位于client中(这两个层可能合为一体)。
         当然,这里也存在需要订阅server端动态内容的应用:那些动态内容不停地从server端推送过来。例如股票实时情报、系统状态报告、天气情况或者其 它的监测应用。它遵循观察者(Observer)模板(也称为发布/订阅模板),这种模板中的远程client注册成为关注于server端对象变化的观 察者。关于设计模板的知识请看Matrix Wiki上的介绍
        那么在HTML页面已经被装载后如何通知浏览器客户端?或者如果有选择地更新页面中一些部分的话,那该怎么做?比如只更新在HTML Table中的那些价格发生变化的股票列?
多种通知解决方案
         让我们对应用进行这样的假设:拥有一个Java web server或者Java应用server,我们试图从server发送通知给client端浏览器。这里的解决方案可以分为:“轮询 (polling)”、“服务端回调(server-side callbacks)”和“消息(messaging)”三类。
轮询
         最简单的解决方案便是“定时刷新页面”。在HTML文档的头部使用HTML META标签,页面便可以每隔N秒自动reload一次。如果在此期间server端数据发生变化,那么我们可以获得新的内容,否则将得到相同的页面。虽 然方法很简单,但是如何设置刷新间隔是让人头疼的大问题。
服务端回调
        因为我们是“身经百战”的Java开发老手,所以经常会用到“服务端回调”。这种方式通过RMI或者CORBA将Server端的对象传输到Java applet客户端。
消息(MOM)
        这种解决方案采用一个作为client的applet,它使用TCP/IP或者无连接的UDP、甚至多播协议来建立与消息中间键server的通讯,然后由server推送消息给client。你可以从例如SoftWired的iBusIBM的MQSeriesBEA的WebLogic Event这些消息产品中直接挑选,或者自己使用基于socket的java.io.ObjectStream定制开发消息软件。
讨论(MOM)
        上面三种解决方案在复杂性、安全性、性能、可测量性、浏览器兼容性、防火墙限制上各有优势、劣势。最佳解决方案依赖于你的应用需求。例如,在共享白板应用中,用户需要直接与“状态”交互,那么server端回调或者消息很可能会大显身手。
         但在浏览器环境下,除非完全使用applet作为整个client应用,否则把来自于server的更新信息集成到页面中并非易事。如何在applet收 到回调或者消息时变更页面相关内容?一个很“痛快”而又“痛苦”的解决方案就是在回调方法中使用AppletContext.showDocument (URL)方法来刷新整个页面。
        由于HTML代码可以直接影响页面布局,直接使用来自server的数据更改HTML部 分内容不是更好吗?这是web应用的理想方案,在server上内容动态改变时,从用户到server所需的交互是最小化的。作为对上面的解决方案的补 充,我开发了Pushlet这种轻量级、瘦客户端的技术,它无需applet或者插件而直接与脚本/HTML集成在一起、使用标准HTTP连接、理论上可 以部署到任何支持Java servlet的server上。但这并不意味着它将替换对前面解决方案,而是在你的开发“工具箱”中添加另一种选择。作为Java构架者/开发者,你可 以自行权衡、选择、决定哪种适合应用的解决方案。
Pushlet原理
        Pushlet的基本使用形式是极为简单的。后面的一些示例会说明这一点。
HTTP流

image
极富生活韵味的“Urban Stream”把我们Connecting Together

        Pushlet 基于HTTP流,这种技术常常用在多媒体视频、通讯应用中,比如QuickTime。与装载HTTP页面之后马上关闭HTTP连接的做法相反, Pushlet采用HTTP流方式将新数据源源不断地推送到client,再此期间HTTP连接一直保持打开。有关如何在Java中实现这种Keep- alive的长连接请参看Sun提供的《HTTP Persistent Connection》W3C的《HTTP1.1规范》。
示例1
        我们利用HTTP流开发一个JSP页面(因为它易于部署,而且它在web server中也是作为servlet对待的),此页面在一个定时器循环中不断地发送新的HTML内容给client:

java 代码
 
  1. <%   
  2.   int i = 1;  
  3.       
  4.   try {  
  5.     while (true) {  
  6.        out.print("

    "+(i++)+"

    "
    );  
  7.        out.flush();  
  8.         
  9.        try {  
  10.             Thread.sleep(3000);  
  11.        } catch (InterruptedException e) {  
  12.        out.print("

    "+e+"

    "
    );  
  13.         }  
  14.      }  
  15.    } catch (Exception e) {  
  16.        out.print("

    "+e+"

    "
    );  
  17.    }  
  18. %>  

        在Pushlet源代码中提供了此页面(examples/basics/push-html-stream.jsp)。上面的页面并不是十分有用,因为在我们刷新页面时,新内容机械地、持续不断地被添加到页面中,而不是server端更新的内容。
示例2
        现在让我们步入Pushlet工作机理中一探究竟。通过运行Pushlet的示例源代码(examples/basics/ push-js-stream.html),我们会看到这个每3秒刷新一次的页面。那么它是如何实现的呢?
        此示例中包含了三个文件:push-js-stream.html、push-js-stream-pusher.jsp、push-js-stream-display.html。
        其中push-js-stream.html是主框架文件,它以HTML Frame的形式包含其它两个页面。
        push-js-stream-pusher.jsp是一个JSP,它执行在server端,此文件内容如下: 

java 代码
 
  1.  7: <%   
  2.  8:   /** Start a line of JavaScript with a function call to parent frame. */  
  3.  9:   String jsFunPre = ";  
  4. 10:     
  5. 11:   /** End the line of JavaScript */  
  6. 12:   String jsFunPost = "') ";  
  7. 13:     
  8. 14:   int i = 1;  
  9. 15:   try {  
  10. 16:     
  11. 17:     // Every three seconds a line of JavaScript is pushed to the client  
  12. 18:     while (true) {  
  13. 19:       
  14. 20:        // Push a line of JavaScript to the client   
  15. 21:        out.print(jsFunPre+"Page "+(i++)+jsFunPost);  
  16. 22:        out.flush();  
  17. 23:          
  18. 24:        // Sleep three secs  
  19. 25:        try {  
  20. 26:             Thread.sleep(3000);  
  21. 27:        } catch (InterruptedException e) {  
  22. 28:             // Let client display exception   
  23. 29:             out.print(jsFunPre+"InterruptedException: "+e+jsFunPost);  
  24. 30:        }  
  25. 31:      }  
  26. 32:    } catch (Exception e) {  
  27. 33:             // Let client display exception   
  28. 34:             out.print(jsFunPre+"Exception: "+e+jsFunPost);  
  29. 35:    }  
  30. 36: %>   

        注 意在示例1和示例2中使用JSP时都存在一个问题:一些servlet引擎在某个client离开时会“吃掉”IOException,以至于JSP页面 将永不抛出此异常。所以在这种情况下,页面循环将会永远执行下去。而这正是Pushlet实现采用servlet的原因之一:可以捕获到 IOException。
        在上面代码的第21行中可以看到在一个定时器循环(3秒/周期)中打印了一些HTML并将它们输出到client浏览器。请注意,这里推送的并非HTML而是Javascript!这样做的意义何在?
         它把类似“

” 的一行代码推送到浏览器;而具有JavaScript引擎的浏览器可以直接执行收到的每一行代码,并调用 parent.push()函数。而代码中的Parent便是浏览器页面中所在Frame的Parent,也就是push-js- stream.html。让我们看看都发生了什么?

js 代码
 
  1. "JavaScript">  
  2. var pageStart=";  
  3. var pageEnd="";  
  4.   // Callback function with message from server.  
  5.   // This function is called from within the hidden JSP pushlet frame  
  6.   function push(content) {  
  7.    
  8.     // Refresh the display frame with the content received  
  9.     window.frames['displayFrame'].document.writeln(pageStart+content+pageEnd);  
  10.     window.frames['displayFrame'].document.close();  
  11.   }  
  12.   
  13.   
  14.   
  15.   
  16.   
  17.        
  18.        
  19.        
  20.        
  21.        
  22.   

         可以看到push-js-stream.html中的push()函数被名为pushletFrame的JSP Frame调用:把传入的参数值写入到displayFrame(此Frame为push-js-stream-display.html)。这是动态 HTML的一个小技巧:使用document对象的writeln方法刷新某个Frame或者Window的内容。
        于是displayFrame成为了用于显示内容的、真正的视图。displayFrame初始化为黑色背景并显示“wait…”直到来自server的内容被推送过来:

WAIT...

         这便是Pushlet的基本做法:我们从servlet(或者从示例中的JSP)把JavaScript代码作为HTTP流推送到浏览器。这些代码被浏览 器的JavaScript引擎解释并完成一些有趣的工作。于是便轻松地完成了从server端的Java到浏览器中的JavaScript的回调。
         上面的示例展示了Pushlet原理,但这里存在一些等待解决的问题和需要增添的特性。于是我建立了一个小型的server端Pushlet框架(其类结 构图表将会展示在下面),添加了一些用在client中的JavaScript库。由于client需要依赖更多的DHTML特性(比如Layers), 我们将首先粗略地温习一些DHTML知识。示例代码见examples/dhtml。
框架的设计
        注意:本章节仅反映了Pushlet server端框架的1.0版本(随着版本升级可能还会重新构造)。
        Pushlet 框架允许client订阅在server端的主题(subject),而server则接收订阅,然后在server端的订阅主题所对应的数据变化时推送 数据到client。此框架的基本设计模板是发布/订阅(Publish/Subscrib),也被称为观察者(Observer)。它具有server 和client两部分组建而成:
        Server端:
        由围绕着Pushlet类的Java类集合构成(见下面的UML类设计图表)。
        Client端:
        脚本与页面:可重用的JavaScript库(pushlet.js)和用来在DHTML client(这里指浏览器)中接收事件的HTML(pushlet.html)组成。
        Client端Java类:
        JavaPushletClient.java和JavaPushletClientListener.java,负责在Java client中接收事件。
        跨越浏览器的DHTML工具库:
        layer.js, layer-grid.js, layer-region.js,用来在DHTML层中显示数据内容。
        最后,还有用于测试事件的生成工具类EventGenerators.java以及一些示例应用。
server端类设计
        下面是server端Java类的UML图表:


image
Pushlet框架Java类UML图

        关键的类:Pushlet、Publisher类、Subscriber接口和Event类。通过HTTP请求调用Pushlet这个servlet,client订阅事件并接收事件。
        Client发送订阅请求时需要表明的内容如下:
        1.订阅事件的主题
        2.接收事件所采用的格式:默认为JavaScript调用,还有XML或者Java序列化对象者三种。目前Pushlet 2.0.2版已经支持AJAX。
        3.使用哪种接收协议(将来实现):TCP/IP、UDP、Multicast。
        示例:用于接收AEX股票价格的请求,默认使用JavaScript调用作为格式。

js 代码
 
  1. http://www.fluidiom.com:8080/servlet/pushlet?subject="/stocks/aex"  

         主题(subject)表示为具有层次的“主题树”(topic-tree)形式。例如:“/stocks”表示与股票价格相关的所有事件,而 “/stocks/aex”表示Amsterdam Exchange公司的股票价格。“/”表示所有事件。这并不时硬性规定,而是由开发者根据应用自行定义。
        当前只有接收方协议是发送到client的HTTP回应流(response stream)。在将来的扩展版本中,接收方协议能够提供多种选择,比如TCP、UDP、RMI、HTTP POST甚至只SMTP。
        Event(事件)类:仅仅是name/value的字符串对(使用java.util.Properties实现)的集合。
        产生Event的方式:Publisher 类为生成的Event提供了发布接口,它内部保存了订阅者(那些实现Subscriber接口的类)列表,并把每个Event发送给那些主题与Event 匹配的订阅者。Event在server端也可以通过能够侦听外部Event的EventGenerators类来生成。另外client可以通过基于 HTTP通讯的Postlet类来发布Event。
        在上面的图表中,为了适配不同请求源(浏览器、Java client程序),PushletSubscriber以及它所包含的那些类提供了多种订阅者的实现。
场景1: 事件订阅


image
浏览器client订阅程序图

        上面的UML程序图中,浏览器client通过Publisher订阅Event。
        Pushlet 作为servlet,通过doGet/doPost方法被调用。由于多个client可以同时调用同一个Pushlet,所以Pushlet本身不能作为 订阅者。取而代之的是,它派发所有的订阅:在每一次调用doGet()/doPost()时,新建PushletSubscriber对象、并使之运行直 至事件循环(eventLoop)结束。PushletSubscriber作为一个实现Subscriber接口的对象,通过join()方法向 Publisher类进行注册的方式将自身添加到Publisher的内部列表。
        面对不同的client类型和协议, PushletSubscriber建立一个相对的ClientAdapter对象,在这个场景中是BrowserPushletAdapter对象。而 对于支持Multipart MIME的浏览器,将建立MultipartBrowserClientAdapter对象。
        最后的deQueue()调用是一个“等待Event的循环”,deQueue的意思为入队。注意此方法将挂起当前线程直到PushletSubscriber的GuardedQueue队列中存入有效的Event。
场景2: 发送和派发事件

image
事件发布程序图

         上图显示了发送一个事件所要经历的程序。它展现了Event如何被生成、被派发给浏览器client。在这个场景中,EventGenerator建立了 一个Event对象,并调用Publisher.publish()将其派发到client。Publisher遍历它内部的订阅者列表,询问这个 Event是否匹配订阅标准(目前只是主题匹配)。如果发现与之匹配的订阅者,则调用该订阅者的send()方法。
        每个 PushletSubscriber对象都有一个GuardedQueue对象,在其中以队列的形式保存着调用send()方法时传入的Event。那么 它为什么不直接将Event推送给BrowserPushletAdapter呢?最重要的原因是我们期望挂起 BrowserPushletAdapter线程,直到GuardedQueue中存在有效的Event,这样就避免了“忙于等待”或者“轮询”方式所带 来的负面影响。第二原因是Publisher可以通知多个client,如果在执行同步的send()调用时,某个慢速的client可能会堵塞所有其它 正在等待通知的client。这正是我在RMI或者CORBA提供的一组client进行同步回调的示例中所看到的设计缺陷。
        GuardedQueue 是个工具类,它使用了读/写模板(readers-writers pattern),此模板采取java.lang.Object.wait()/notifyAll()方法实现可被监控的挂起。通过使用读/写模板,使 GuardedQueue类具有进行对象入队/出队(enqueue/dequeue)操作的能力。当队列为空时,GuardedQueue调用 deQueue()方法时,此时调用线程将被挂起,直到有对象入队为止。相反,当队列已满时调用enQueue(),线程也将挂起。在 BrowserPushletSubscriber获得出队的Event对象后,它将调用BrowserPushletAdapter的push()方 法,后者将格式化Event为JavaScript代码或者XML以及其它格式),并将它发送到浏览器。比如Philips股票价格为123.45的 JavaScript代码格式如下:

js 代码

Client端框架
        由于这是对于所有浏览器client的通用任务,所以Pushlet Client端框架提供了两个可重用的文件:
pushlet.html和pushlet.js。
        Pushlet.html本身是被附着在一个隐藏的HTML Frame中。这个Frame的parent调用并实现push()方法。
        pushlet.html :被包含在client端的HTML文档中的Frame中。它可以传入主题标识和背景颜色两个参数。而它所做的最重要的工作是下面的push方法:

js 代码
 
  1. function push() {  
  2.         // 根据传入的参数建立PushletEvent object  
  3.         // push.arguments是来自server的Event数据  
  4.         pushletEvent = new PushletEvent(push.arguments)  
  5.   
  6.         // 更新状态Frame:显示闪光表示接收数据  
  7.         updateStatusFrame();  
  8.           
  9.         // parent frame是否准备好接收Event?  
  10.         if (!parent.onPush) {  
  11.                 return;  
  12.         }  
  13.           
  14.         // 把Event转发给parent frame指定的处理方法  
  15.         parent.onPush(pushletEvent);  
  16. }  

        Push ()函数首先根据传入的参数建立了一个JavaScript对象——pushletEvent。接着使用updateStatusFrame()显示闪 光,表示我们正在接收Event数据,如果parent frame存在onPush()函数,则将前面建立的PushletEvent对象作为参数调用parent frame指定的处理方法。
        在pushlet.js 中的PushletEvent类代码如下:

js 代码
 
  1. /* Object to represent nl.justobjects.pushlet.Event in JavaScript.  
  2.    Arguments are an array where args[i] is name and args[i+1] is value  
  3. */  
  4. function PushletEvent(args) {  
  5.    // Map存放Name/Value pairs  
  6.    this.map = new Map();  
  7.      
  8.    // 设置成员方法  
  9.    this.getSubject = PushletEventGetSubject  
  10.    this.put = PushletEventPut  
  11.    this.get = PushletEventGet  
  12.    this.toString = PushletEventToString  
  13.    this.toTable = PushletEventToTable  
  14.   
  15.    // 将传入的参数值放入到map中  
  16.    for (var i=0; i < args.length; i++) {  
  17.      this.put(args[i], args[++i] );  
  18.    }  
  19. }  
  20.   
  21. // 获取事件主题  
  22. function PushletEventGetSubject() {  
  23.   return this.map.get('subject')  
  24. }  
  25.   
  26. // 获取事件属性  
  27. function PushletEventGet(name) {  
  28.   return this.map.get(name)  
  29. }  
  30.   
  31. // 存放事件属性  
  32. function PushletEventPut(name, value) {  
  33.   return this.map.put(name, value)  
  34. }  
  35.   
  36. function PushletEventToString() {  
  37.   return this.map.toString();  
  38. }  
  39.   
  40. // 将map内容转化为HTML Table  
  41. function PushletEventToTable() {  
  42.   return this.map.toTable();  
  43. }  

        PushletEvent使用了一个我增加的Map JavaScript对象,它类似于java.util.Hashtable。
Pushlet协议
        详见http://www.pushlets.com/doc/protocol.html
应用
        Pushlet可以开发多种类型的web应用。由于此框架允许client主动更新事件(通过Postlet),所以应用就并不只是被动地推送数据了。每个Pushlet应用都可以根据下面进行分类:
        事件由server发起、还是client发起或者两者都有可能;状态是否保持在server、还是在client或者两者都有可能。
        由于事件不但被做成了对JavaScript有效,而且也是其它脚本化的插件能够接收实时的事件更新。例如你可以脚本化Macromedia Flash或者VRML应用。
        为了说明Pushlet应用的范围,下面提供了一些简单的demo。
监控
        例如股票、天气、投票、机场到达系统,这些应用都可以采用Pushlet对实时数据进行监控。
        这是一个实时FX股票/新闻应用:www.rabotreasuryweb.com (IE only). 另一个部署Pushlet的实时股票/新闻应用:www.marketnews.com.
游戏
        从象棋到描述危机和垄断者的游戏。
分布式MVC
         这涉及到了在用户接口框架(例如Java Swing和微软MFC)中常见的设计模板。在分布式MVC的各种变体中,模式层位于server,而client控制着是视图层和控制层。Client 通过控制进而修改模式,然后模式将通知所有依附的视图,而视图将进行自我刷新。
        一些应用具有web前端(front end),其数据存放在server上可被多个用户更新。比如预订系统和登记系统。如果一个client完成一次更新,而其它client却不能马上见到 变化直至刷新页面。在某些情况下,这是很简单、可行的解决方案,但同时也存在着用户需要同步变化的情况。这种情况下,应用可以使用Pushlet简单地将 URL作为单一事件推送到client,client接收到这个URL后将刷新页面。
        另外一点值得注意的示例是争议颇多 的EJB。尽管Java client能够直接和EJB对话(通过RMI或者CORBA),但多数情况下则是由servlet和作为client前端的JSP来完成。在这种情况 下,“通知”工作变得很艰难。使用Pushlet,EJB可以在其状态发生改变时通知依附于它的web client。
Web表示层
        在放弃使用PowerPonit作Java课程讲解工具后,我开发了一个基于XML的内容管理框架。由于在某些情形下,教室没有“卷轴工”,但是所有的学生人手一台网络计算机,所以我开发了这个简单的应用,它使我能够同步改变学生和我的页面内容。
用户辅助
        这种类型的应用可用于call center、银行、帮助桌面、电子商务web应用。当你由于问题而拨打call center电话时,代理程序可以使你通过上网的方式浏览解决方案、供货等信息。
        使用EJB作为后台和JSP作为前台,client可以买/卖外币。一个“AutoTrader”对象自动提供处理,如果自动处理失败或者client请求人工处理时,一个“处理干预”将发生,处理者将被通知并提供相应的服务。
社区工具
        这是一种多用户参加实时会话的应用。我正在计划扩充Pushlet框架,使其支持多用户session的特性。目前可以实现简单的web聊天,我称之为WCQ,大家可以在Pushlet源代码的example中见到它。
比较
        本章节对Pushlet与基于CORBA/RMI的Java applet解决方案进行一下比较。
优势
        直接与浏览器中的DHTML集成。
        标准的HTTP端口和协议:消息和RMI/CORBA使用非标准端口(相对HTTP标准端口而言),遇到“防火墙”、“禁止回调”、“禁止接收UDP数据”的浏览器安全限制时可能无法工作。
        client负载:基于CORBA/RMI的Java applet使client在启动时更加沉重,并消耗更多的资源。
        无需额外的server:消息和RMI/CORBA需要单独的server产品。Pushlet理论上可以在任何server引擎上运行,并具备连接管理和多线程能力。
缺点
        跨越浏览器的DHTML:Pushlet需要使用能工作在任何平台、所有浏览器版本的DHTML库。
        可测量性:当100个以上的client通过Pushlet连接到server时,server上的线程和socket资源都将出现紧张。而解决这一问题的方式就是使用单独的Pushlet服务器。
        Web server问题:一般的web server往往不是为长连接而设计的。针对这一问题的解决方案与上面的可测量性相同。
        代理缓存:一些代理服务器可能缓存HTTP数据。
参考资源:
        什么是Comet?
        Pushlet官网
        Pushlet白皮书
        JavaWorld《An in-depth look at RMI callbacks》
        JavaWorld《POSTing via Java/ Learn how to POST data to Web servers in Java》
        avaWorld《POSTing via Java revisited. Learn how to display the HTML document returned by the Web server》J
        JavaWorld《Connect to a Java server via HTTP》
        Doug Lea编写的《Concurrent Programming in Java – 2nd edition》
        Dynamic Duo[Cross-Browser Dynamic HTML]
        Danny Goodman编写的 《Dynamic HTML: The Definitive Reference》
进阶资源:
        Pushlet安装手册
        Pushlet协议解释
        Pushlet Cookbook
        Pushlet API文档
感谢阅读此文

赞(0) 打赏
分享到: 更多 (0)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏