SIP 简介,第 2 部分:SIP Servlet



 作者:Emmanuel ProulxJ8y思考者日记网-束洋洋个人博客

摘要

会话发起协议 (SIP) 是一种重要的信令协议,电信行业正在迅速采用这种协议以构建下一代应用程序。Java 是 SIP 开发的杰出平台,尤其是在进行服务器端开发时。与 HTTP servlet 相似,SIP Servlet API 可以使 SIP 服务的开发变得更轻松。本文将介绍 SIP servlet 技术,并提供一个带有注释的示例。J8y思考者日记网-束洋洋个人博客

简介

即时消息传递正在改变人们的生活。它是一种非常有价值的工具,结合了电子邮件、互联网电话以及文件传输应用程序的优点。我甚至可以看到谁在线、谁在忙碌。当然,人们也可以用它来长时间地进行不创造任何效益的聊天。但它也使我能够在上司与客户会谈的过程中为他提供必要的关键信息。J8y思考者日记网-束洋洋个人博客

因此,市面上出现如此之多的不同种类的即时消息传递应用程序也就不足为怪了。有这么多的选择总是件好事,不是吗?但如果我使用的应用程序与上司使用的不同,情况又会如何?我将陷入大麻烦,因为大多数此类应用程序都使用专用协议。J8y思考者日记网-束洋洋个人博客

SIP 为我们带来了福音。SIP 很有可能成为标准的即时消息传递协议。J8y思考者日记网-束洋洋个人博客

在本文中,我将开发一个简单的 SIP 应用程序 — 一个允许 SIP 即时消息传递程序彼此联系并互相传播消息的聊天室服务器端。J8y思考者日记网-束洋洋个人博客

SIP SIMPLE

SIMPLE 是 SIP 即时消息传递和在线状态利用扩展 (SIP Instant Messaging and Presence Leveraging Extension) 的缩写,这是一个工作组,也是一组 SIP 扩展。其中的一个扩展是 MESSAGE 消息。可以用它来发送包含文本和二进制内容的任何组合的即时消息。这种消息使用起来极为简单,因此我决定利用它来开发我们的第一个 SIP 应用程序。J8y思考者日记网-束洋洋个人博客

TextClient

为了测试我们的应用程序,我提供了一个简单的 SIP 即时消息传递应用程序。(请参见本文结尾处的“下载”部分。)这个应用程序向其他消息传递程序或服务器发送 MESSAGE 消息。用户界面包括客户端地址、好友地址的输入域、文本消息和提交按钮。图 1 显示了正在运行的 TextClient。J8y思考者日记网-束洋洋个人博客

图 1 
图 1. 运行中的 TextClientJ8y思考者日记网-束洋洋个人博客

要启动 TextClient,请使用以下命令:J8y思考者日记网-束洋洋个人博客

java -jar textclient.jar dev2dev.textclient.TextClient username portJ8y思考者日记网-束洋洋个人博客

该命令使用 JAIN SIP API 参考实现作为一个 SIP 协议栈。我们提供了该工具的源代码,如果您希望了解更多信息,我建议您读一下源代码。J8y思考者日记网-束洋洋个人博客

ChatRoomServer

下面是我们示例的需求:J8y思考者日记网-束洋洋个人博客

聊天室是一个虚拟空间,多个即时消息传递应用程序可以在其中进行交互。进入聊天室的消息将广播给聊天室中的所有其他人。换言之,所有用户都能看到所有消息。这意味着,在消息到达我们的服务器应用程序时,用户地址将被添加到一个列表中。然后,消息将发送给该列表中的所有用户。J8y思考者日记网-束洋洋个人博客

此外,还可以实现“命令”。命令以正斜杠 (/) 开头,不会被广播,而是由服务器自己处理来实现特殊功能。我将实现的命令包括:J8y思考者日记网-束洋洋个人博客

  • /join:默默地进入一个聊天室,不广播任何消息。
  • /who:输出该聊天室内所有用户的列表。
  • /quit:离开聊天室,不再有消息传入。

SIP Servlet API

SIP Servlet API (JSR 116) 是一个服务器端接口,描述了 SIP 组件或服务的容器。这是开发 ChatRoomServer 的理想之选。下载该规范并进行解压缩。生成的文件夹中包含一些库(servlet.jar 和 sipservlet.jar)以及文档。我无法获得运行示例 SIP servlet 的参考实现,因此我想您也不必费心去找它了。J8y思考者日记网-束洋洋个人博客

SIP servlet 最核心的概念是包容。SIP 服务是部署或运行在一个容器或 SIP 应用服务器上的打包 SIP servlet。容器提供了多种可供应用程序利用的服务,如自动重试、消息调度和排队、分流与合并、状态管理。您的应用程序仅包含高级的消息处理和业务逻辑。这使得 SIP 服务的开发轻而易举。J8y思考者日记网-束洋洋个人博客

 J8y思考者日记网-束洋洋个人博客

本文的目标不是要帮助您了解 SIP Servlet API 技术的方方面面。因此我只简要介绍了该 API 和示例代码。有关更多信息,请参见本文结尾处的“参考资料”一节。J8y思考者日记网-束洋洋个人博客

服务器端代码

如果您过去从事过 HTTP servlet 的开发,那么服务器端代码看起来应该非常熟悉。如果您不了解 servlet,那么应该先自行熟悉一下。SIP Servlet 规范是 HTTP Servlet 规范的扩展。语法、容器的行为甚至方法名称都是相似的。J8y思考者日记网-束洋洋个人博客

现在,我将详细分析相关示例。它大体上包含三个部分:J8y思考者日记网-束洋洋个人博客

1. 生命周期方法

在 servlet 启动或者关闭时,容器将调用这些方法J8y思考者日记网-束洋洋个人博客

折叠复制内容到剪贴板
  1. public class ChatRoomServer extends SipServlet {  
  2.   
  3. /** Context attribute key to store user list. */  
  4. public static String THE_LIST="dev2dev.chatroomserver.userList";  
  5.   
  6. /** Init parameter key to retrieve the chat room's address. */  
  7. public static String THE_NAME="dev2dev.chatroomserver.name";  
  8.   
  9. /** This chat room server's address, retrieved from the init params. */  
  10. public String serverAddress;  
  11.   
  12. /** This is called by the container when starting up the service. */  
  13. public void init() throws ServletException {  
  14.     super.init();  
  15.     getServletContext().setAttribute(THE_LIST,new ArrayList());  
  16.     serverAddress = getServletConfig().getInitParameter(THE_NAME);  
  17. }  
  18.   
  19. /** This is called by the container when shutting down the service. */  
  20. public void destroy() {  
  21.     try  
  22.     {  
  23.         sendToAll(serverAddress, "Server is shutting down -- goodbye!");  
  24.     } catch (Throwable e)  
  25.     { //ignore all errors when shutting down.  
  26.         e.printStackTrace();  
  27.     }  
  28.     super.destroy();  
  29. }  
  30. ...  

在初始化方法中,我创建一个所有会话共享的全局(上下文范围)属性。这是用户的列表。我还检索了这个聊天室的地址(servlet 参数)以备将来使用。J8y思考者日记网-束洋洋个人博客

2. 消息处理方法

SIP servlet 与 HTTP servlet 略有不同。对于 HTTP servlet,您处理传入的请求,并发出响应消息。而对于 SIP servlet,您可以同时发送和接收请求和响应。下面将说明具体方法。J8y思考者日记网-束洋洋个人博客

收到消息(请求和响应)时,容器将调用以下方法。容器按照下面图标中的顺序调用这些方法,您也可以改写这些方法来根据消息的类型处理消息:J8y思考者日记网-束洋洋个人博客

void service(ServletRequest, ServletResponse)

如果您对其进行改写,不要忘记调用 super.service()J8y思考者日记网-束洋洋个人博客

默认实现调用以下方法之一:J8y思考者日记网-束洋洋个人博客

void doRequest(SipServletRequest)

如果您对其进行改写,不要忘记调用 super.doRequest()J8y思考者日记网-束洋洋个人博客

默认实现调用以下方法之一:J8y思考者日记网-束洋洋个人博客

void doResponse(SipServletResponse)

如果您对其进行改写,不要忘记调用 super.doResponse()J8y思考者日记网-束洋洋个人博客

默认实现调用以下方法之一:J8y思考者日记网-束洋洋个人博客

以下请求方法之一(自解释):J8y思考者日记网-束洋洋个人博客

  • doAck(SipServletRequest)
  • doBye(SipServletRequest)
  • doCancel(SipServletRequest)
  • doInfo(SipServletRequest)
  • doInvite(SipServletRequest)
  • doMessage(SipServletRequest)
  • doNotify(SipServletRequest)
  • doOptions(SipServletRequest)
  • doPrack(SipServletRequest)
  • doRegister(SipServletRequest)
  • doRequest(SipServletRequest)
  • doResponse(SipServletResponse)
  • doSubscribe(SipServletRequest)

以下响应方法之一:J8y思考者日记网-束洋洋个人博客

  • doProvisionalResponse(SipServletResponse) — 对应于 1xx 类响应消息。
  • doSuccessResponse(SipServletResponse) — 对应于 2xx 类响应消息。
  • doRedirectResponse(SipServletResponse) — 对应于 3xx 类响应消息。
  • doErrorResponse(SipServletResponse) — 对应于 4xx、5xx 和 6xx 类响应消息。

例如,一条 MESSAGE 消息可能会调用以下方法:J8y思考者日记网-束洋洋个人博客

  1. service(),传入一个 SipServletRequest(您必须进行类型转换)和 null
  2. doRequest()
  3. doMessage()

通常只重写最后一级的方法,除非使用了非标准 SIP 消息或者您希望收集有关消息的统计数据。J8y思考者日记网-束洋洋个人博客

现在,对于处理我们的即时消息的代码:J8y思考者日记网-束洋洋个人博客

折叠复制内容到剪贴板
  1. /** This is called by the container when a MESSAGE message arrives. */  
  2. protected void doMessage(SipServletRequest request) throws  
  3.         ServletException, IOException {  
  4.   
  5.     request.createResponse(SipServletResponse.SC_OK).send();  
  6.   
  7.     String message = request.getContent().toString();  
  8.     String from = request.getFrom().toString();  
  9.   
  10.     //A user asked to quit.  
  11.     if(message.equalsIgnoreCase("/quit")) {  
  12.         sendToUser(from, "Bye");  
  13.         removeUser(from);  
  14.         return;  
  15.     }  
  16.   
  17.     //Add user to the list  
  18.     if(!containsUser(from)) {  
  19.         sendToUser(from, "Welcome to chatroom " + serverAddress +  
  20.                 ". Type '/quit' to exit.");  
  21.         addUser(from);  
  22.     }  
  23.   
  24.     //If the user is joining the chat room silently, no message  
  25.     //to broadcast, return.  
  26.     if(message.equalsIgnoreCase("/join")) {  
  27.         return;  
  28.     }  
  29.   
  30.     //We could implement more IRC commands here,  
  31.     //see http://www.mirc.com/cmds.html  
  32.     sendToAll(from, message);  
  33. }  
  34.   
  35. /** 
  36.  * This is called by the container when an error is received 
  37.  * regarding a sent message, including timeouts. 
  38.  */  
  39. protected void doErrorResponse(SipServletResponse response)  
  40.         throws ServletException, IOException {  
  41.     super.doErrorResponse(response);  
  42.     //The receiver of the message probably dropped off. Remove  
  43.     //the receiver from the list.  
  44.     String receiver = response.getTo().toString();  
  45.     removeUser(receiver);  
  46. }  
  47.   
  48. /** 
  49.  * This is called by the container when a 2xx-OK message is 
  50.  * received regarding a sent message. 
  51.  */  
  52. protected void doSuccessResponse(SipServletResponse response)  
  53.         throws ServletException, IOException {  
  54.     super.doSuccessResponse(response);  
  55.     //We created the app session, we have to destroy it too.  
  56.     response.getApplicationSession().invalidate();  
  57. }  

在一条 MESSAGE 消息到达时,将调用第一个方法。它首先回复一条 200 OK 消息,表明已经收到了消息。随后将处理服务器命令,如/join。最后,它调用一个业务逻辑方法来广播传入的消息。J8y思考者日记网-束洋洋个人博客

传入的错误响应消息表明上一个请求失败了。这可能意味着一名用户断开了连接。只需从列表中删除该用户即可。J8y思考者日记网-束洋洋个人博客

成功响应消息则意味着即时消息传递程序已经正确接收了上一条 MESSAGE 消息。我们不再需要会话,因此我们将丢弃它。通常情况下,MESSAGE 消息是以无状态的形式发送的,消息之间不会保留连接信息。(对于 INVITE 消息来说,情况并非如此,它会打开一个有状态的会话,直至发送 BYE 为止。)J8y思考者日记网-束洋洋个人博客

3. 业务逻辑代码

代码的其余部分由 helper 方法组成。前两种方法将消息外发至即时消息传递程序。要发送一条消息,请使用工厂来创建:J8y思考者日记网-束洋洋个人博客

  • 一个 SipApplicationSession(稍后将详细介绍)
  • 一条请求消息

此时,我们可以随心所欲地修改消息。在我们的例子中,我们在有效负载中添加了即时消息文本。最后,我们发送这条消息。J8y思考者日记网-束洋洋个人博客

折叠复制内容到剪贴板
  1. private void sendToAll(String from, String message)  
  2.         throws ServletParseException, IOException {  
  3.     SipFactory factory = (SipFactory)getServletContext().  
  4.         getAttribute("javax.servlet.sip.SipFactory");  
  5.   
  6.     List list = (List)getServletContext().getAttribute(THE_LIST);  
  7.     Iterator users = list.iterator();  
  8.     while (users.hasNext()) { //Send this message to all on the list.  
  9.         String user = (String) users.next();  
  10.   
  11.         SipApplicationSession session =  
  12.             factory.createApplicationSession();  
  13.         SipServletRequest request = factory.createRequest(session,  
  14.                 "MESSAGE", serverAddress, user);  
  15.         String msg = from + " sent message: n" + message;  
  16.         request.setContent(msg.getBytes(), "text/plain");  
  17.         request.send();  
  18.     }  
  19. }  
  20.   
  21. private void sendToUser(String to, String message)  
  22.         throws ServletParseException, IOException {  
  23.     SipFactory factory = (SipFactory)getServletContext().  
  24.         getAttribute("javax.servlet.sip.SipFactory");  
  25.     SipApplicationSession session = factory.createApplicationSession();  
  26.     SipServletRequest request = factory.createRequest(session,  
  27.             "MESSAGE", serverAddress, to);  
  28.     request.setContent(message.getBytes(), "text/plain");  
  29.     request.send();  
  30. }  
  31.   
  32. private boolean containsUser(String from) {  
  33.     List list = (List)getServletContext().getAttribute(THE_LIST);  
  34.     return list.contains(from);  
  35. }  
  36.   
  37. private void addUser(String from) {  
  38.     List list = (List)getServletContext().getAttribute(THE_LIST);  
  39.     list.add(from);  
  40. }  
  41.   
  42. private void removeUser(String from) {  
  43.     List list = (List)getServletContext().getAttribute(THE_LIST);  
  44.     list.remove(from);  
  45. }  
  46.   
  47. }  

部署描述文件

处理 HTTP servlet 时,必须编写 web.xml 部署描述文件。而在 SIP servlet 环境中,对应的文件是 sip.xml,我们在其中列出 SIP servlet、初始化参数和映射(哪个 SIP servlet 将处理哪条 SIP 消息)。我非常乐意提供有关这个文件的语法的更多信息的链接,但唯一可用的数据就是 SIP Servlet 规范第 15.5 节中的 DTD。其语法类似于 web.xml,但 <servlet-mapping> 标记例外。它不会将一个 URL 模式映射到 servlet,而是(基于域和子域的内容)描述一个条件,SIP 请求必须满足这个条件才能被映射到 servlet。SIP Servlet 规范的第 11 节描述了此映射使用的所有域、子域和条件。J8y思考者日记网-束洋洋个人博客

请注意,此映射只用于初始请求,同一个会话/对话中的后续请求由处理初始请求的 servlet 处理。J8y思考者日记网-束洋洋个人博客

ChatRoomServer 需要的 XML 代码如下:J8y思考者日记网-束洋洋个人博客

折叠复制内容到剪贴板
  1. <?xml version="1.0" encoding="UTF-8"?>  
  2. <!DOCTYPE sip-app  
  3.    PUBLIC "-//Java Community Process//DTD SIP Application 1.0//EN"  
  4.    "http://www.jcp.org/dtd/sip-app_1_0.dtd">  
  5. <sip-app>  
  6.    <servlet>  
  7.       <servlet-name>ChatRoomServer</servlet-name>  
  8.       <servlet-class>dev2dev.chatroomserver.ChatRoomServer</servlet-class>  
  9.       <init-param>  
  10.          <param-name>dev2dev.chatroomserver.name</param-name>  
  11.          <!-- This will be replaced by the build script -->  
  12.          <param-value>sip:chatroomname@serveraddress</param-value>  
  13.       </init-param>  
  14.    </servlet>  
  15.   
  16.    <servlet-mapping>  
  17.       <servlet-name>ChatRoomServer</servlet-name>  
  18.       <pattern>  
  19.          <and>  
  20.             <equal>  
  21.                <var>request.uri.user</var>  
  22.                <!-- This will be replaced by the build script -->  
  23.                <value>chatroomname</value>  
  24.             </equal>  
  25.            <equal>  
  26.              <var>request.method</var>  
  27.              <value>MESSAGE</value>  
  28.            </equal>  
  29.          </and>  
  30.       </pattern>  
  31.    </servlet-mapping>  
  32.   
  33. </sip-app>  

看起来似乎非常复杂,但实际上并非如此。Servlet 映射仅仅表达了以下意思:J8y思考者日记网-束洋洋个人博客

如果请求 URI 的用户名部分等于 chatroomname,则将 MESSAGE 请求映射到 ChatRoomServer Servlet。J8y思考者日记网-束洋洋个人博客

该聊天室名称只是一个占位符。在编译过程中,会用实际的聊天室名称替换关键字“chatroomname”。J8y思考者日记网-束洋洋个人博客

这么做有什么用呢?简而言之,您可以多次部署同一服务,每次都使用唯一的聊天室名称,而消息将自动路由到正确的 servlet。J8y思考者日记网-束洋洋个人博客

编译、打包、部署

需要对 SIP servlet 进行编译,然后将其打包成 SAR 文件 (Servlet ARchive)。这种文件在功能上等效于 WAR 文件,所使用的结构也是相同的。请参见图 2:J8y思考者日记网-束洋洋个人博客

图 2 
图 2. SAR 文件结构J8y思考者日记网-束洋洋个人博客

最后一步是部署,这取决于您的 SIP 应用服务器。通常需要将 SAR 文件复制到一个部署文件夹中,随后部署应用程序。J8y思考者日记网-束洋洋个人博客

下面的 Ant 脚本可帮助您实现所有这些目标:J8y思考者日记网-束洋洋个人博客

Python Code复制内容到剪贴板
  1. <project name="ChatRoomServer"  default="build"  basedir=".">  
  2.   
  3.     <!-- Change this to specify the name of the chat room. In order to  
  4.          send messages to this chat room, simply deploy just4fun.sar, and  
  5.          use the address sip:just4fun@10.0.2.5060:5060. -->  
  6.     <property name="chatroomname" value="just4fun" />  
  7.   
  8.     <!-- Change this to the address and port of the SIP server. -->  
  9.     <property name="serveraddress" value="10.0.2.69:5060" />  
  10.   
  11.     <!-- Change this to the location of the SAR deployment folder. -->  
  12.     <property name="sar.deployment" value="" />  
  13.   
  14.     <property name="src" value="${basedir}/src" />  
  15.     <property name="lib" value="${basedir}/lib" />  
  16.     <property name="tmp" value="${basedir}/tmp" />  
  17.   
  18.     <path id="classpath">  
  19.         <fileset dir="${lib}"/>  
  20.     </path>  
  21.   
  22.     <target name="build">  
  23.         <mkdir dir="${tmp}"/>  
  24.         <mkdir dir="${tmp}/WEB-INF"/>  
  25.         <mkdir dir="${tmp}/WEB-INF/classes"/>  
  26.         <mkdir dir="${tmp}/WEB-INF/lib"/>  
  27.         <javac debug="true" srcdir="${src}" destdir="${tmp}/WEB-INF/classes">  
  28.             <classpath refid="classpath"/>  
  29.         </javac>  
  30.         <copy todir="${tmp}/WEB-INF" file="${basedir}/sip.xml"/>  
  31.         <replace file="${tmp}/WEB-INF/sip.xml" token="chatroomname" value="${chatroomname}"></replace>  
  32.         <replace file="${tmp}/WEB-INF/sip.xml" token="serveraddress" value="${serveraddress}"></replace>  
  33.         <zip destfile="${basedir}/${chatroomname}.sar">  
  34.             <zipfileset dir="${tmp}"/>  
  35.         </zip>  
  36.         <copy file="${basedir}/${chatroomname}.sar" todir="${sar.deployment}"/>  
  37.     </target>  
  38.   
  39. </project>  

结果

聊天室应用程序运行后,尝试通过运行两个 TextClient 实例来访问它。确保同一台计算机上运行的 SIP 应用程序使用不同的端口。下面的示例显示了同一台计算机上运行的全部三个应用程序:J8y思考者日记网-束洋洋个人博客

  • 运行 ChatRoomServer 的 SIP 应用服务器,地址为 sip:just4fun@10.0.2.69:5060
  • 地址为 sip:snoopy71@10.0.2.69:5061 的 Text client。
  • 地址为 sip:maria119@10.0.2.69:5062 的 Text client。

图 3 显示了结果。J8y思考者日记网-束洋洋个人博客

图 3 
图 3. TextClient 与 ChatRoomServer 交互J8y思考者日记网-束洋洋个人博客

复杂应用程序

我相信,与您在现实中要构建的应用程序相比,本文中的示例只是小菜一碟。实际上,大多数 SIP 应用程序所包含的代码都远超过我们的示例。J8y思考者日记网-束洋洋个人博客

会话和状态:通常,SIP 应用程序是一个状态机,其中长期维护着(有状态的)呼叫或会话,直至连接断开为止。对于 SIP servlet 来说,呼叫是使用 SipApplicationSession 表示的,其中可包含属性(状态)。在一次呼叫中,每个对话(呼叫的组成部分)都是使用SipApplicationSession 内的 SipSession 表示的。(两个人之间的一个背对背会话可能蕴含一个 SipApplicationSession 和两个SipSession。一次会议呼叫可能包含多个 SipSession。)它们也能包含属性。容器将根据消息的上下文自动提供正确的会话对象。J8y思考者日记网-束洋洋个人博客

分层设计:最糟糕的莫过于将所有代码纳入一个庞大的 SIP servlet 中。您需要利用相对独立的分层来设计复杂的应用程序。显而易见,其中的一个分层就是包括连接池的数据库层。但您可能还希望分出一个独立于 SIP 信令的业务逻辑层。另外一个方面是有效负载分析,这应该被构建为可重用层。J8y思考者日记网-束洋洋个人博客

其他技术:目前有许多高级 SIP servlet 技术,其中包括请求代理、重定向和循环、会话超时管理、身份验证、国际化、TCP 支持、计时器、会话监听器和错误管理。显然,本文的篇幅不足以涵盖所有这些主题,但您可以在 SIP Servlet 规范中找到有关这些主题的信息。J8y思考者日记网-束洋洋个人博客

示例:请参见参考资料部分,其中提供的几个示例可帮助您进一步了解复杂 SIP 编程。J8y思考者日记网-束洋洋个人博客

总结

各种标准促进了互操作性,而互操作性又促进了协作。无论是与好友谈天说地,还是传输一个重要的文件,协作总是好事。J8y思考者日记网-束洋洋个人博客

SIP 是一种非常有前景的电信标准,而 SIP Servlet API 则是一种轻松快捷开发服务器端 SIP 应用程序的出色方法。在本文中,您已经通过一个简单示例了解了 SIP servlet 编程的大体情况。希望这篇文章能帮助您踏上协作之路。J8y思考者日记网-束洋洋个人博客

下载

  • TextClient 应用程序
  • ChatRoomServer 应用程序

其他阅读材料

Emmanuel Proulx 是 J2EE 和 SIP 方面的专家。他是经过认证的 WebLogic Server 工程师。J8y思考者日记网-束洋洋个人博客

 

(转载本站文章请注明作者和出处 思考者日记网|束洋洋个人博客 ,请勿用于任何商业用途)

『访问 思考者日记网404页面 寻找遗失儿童』

告知
  •     本站90%以上文章均属原创,部分转载已加上原作者出处。 如需转载本站文章请您务必保留本站出处!
  •     打广告评论者请自重,请为广大网友提供一个健康干净的网络空间。
  •  感谢主机屋提供网站空间;
  •  感谢万网阿里云提供域名解析;
  •  感谢EmpireCMS提供CMS系统;
  •  感谢bootstrap展示本站前端页面;
  •  感谢Glyphicons Halflings提供字体;
  •  感谢大家一直以来对本站的喜爱,感谢大家!
近期文章 建议与反馈