Spring WebSocket教程(二)

admin 发表于 2017-4-4 22:07:17 | 栏目:站长教程 |分类:[其他教程]
实现目标5 b3 G# W6 c* N/ c. z
这一篇文章,就要直接实现聊天的功能,并且,在聊天功能的基础上,再实现缓存一定聊天记录的功能。8 f! A. W$ S0 n( Q, y, k
第一步:聊天实现原理$ t& K2 f- a" M* u# {" Y& W
首先,需要明确我们的需求。通常,网页上的聊天,都是聊天室的形式,所以,这个例子也就有了一个聊天的空间的概念,只要在这个空间内,就能够一起聊天。其次,每个人都能够发言,并且被其他的人看到,所以,每个人都会将自己所要说的内容发送到后台,后台转发给每一个人。
+ u7 |* T- d' _( ^1 M+ y在客户端,可以用Socket很容易的实现;而在web端,以前都是通过轮询来实现的,但是WebSocket出现之后,就可以通过WebSocket像Socket客户端一样,通过长连接来实现这个功能了。$ k- X! W9 s0 W1 p
第二步:服务端基础代码. [: C: |9 @  i) a  g! A2 f9 C
通过上面的原理分析可以知道,需要发送到后台的数据很简单,就是用户信息,聊天信息,和所在的空间信息,因为是一个简单的例子,所以bean就设计的比较简单了:
  1. <font color="#000000">public class UserChatCommand {  ( J7 n: w. h. ]+ p6 t. l
  2.     private String name;  # F0 e9 ]5 c- I3 j: S( y& E
  3.     private String chatContent;  
    ( c1 c/ d) ^- W+ T6 s! a" E; l
  4.     private String coordinationId;  6 ?) X6 u9 B5 b
  5.   
    0 C/ W) b" r- a1 J3 U
  6.     public String getName() {  
    % ^' U, @+ Z( ]' A# b) c2 F
  7.         return name;  " B6 J0 m, ~& e5 F; L
  8.     }  ! O8 s" H! E' _* p: o" g
  9.   6 J# u$ W& N( Z0 o2 D/ X' P
  10.     public void setName(String name) {    R* c7 V" Q5 w. J0 I+ Z
  11.         this.name = name;  ) A1 @4 w' B  |1 P- `* U8 V
  12.     }  
    ! P& N$ y% q7 u
  13.   8 ^1 s9 [* a$ F# t8 `
  14.     public String getChatContent() {  # K& @( q# q; B& b  ?, W
  15.         return chatContent;  
    " ~' \" U* h6 T: e8 v) }
  16.     }  : U; L2 c  A( L! E  j
  17.   9 b8 }4 `% x7 [& k2 R
  18.     public void setChatContent(String chatContent) {  
    - m+ ]" Z; y3 H  ~" F
  19.         this.chatContent = chatContent;  
    2 \; h) j% I9 n- T3 A9 K
  20.     }  2 `/ S! z  A3 q/ c3 ~0 b  }
  21.   
    5 w. `7 p0 x1 a. v  Y" T, l) r6 N, G
  22.     public String getCoordinationId() {  + R! e3 A9 B% U" D4 H! E1 T$ d
  23.         return coordinationId;  
    : F$ X: x1 Z0 j4 W& e6 W/ X: z
  24.     }  ' l+ ~+ C2 S, Z
  25.   
    $ ]9 D: r' p+ L: a8 ]) c* k4 b
  26.     public void setCoordinationId(String coordinationId) {  - j7 P- ^. g- s8 C% h8 q+ @
  27.         this.coordinationId = coordinationId;  
    3 ]  O/ I) w# Z7 |; ^% |2 Z
  28.     }  
    4 L! n0 c1 f7 m4 r8 \
  29.   $ x: v+ B. X1 S; g1 S0 ]. h1 K  B
  30.     @Override  , Q  t" W2 V+ A" P4 g
  31.     public String toString() {  
    * _% X+ U) I5 j# E  [# w* c
  32.         return "UserChatCommand{" +  
    # Z" Q1 o4 g8 D8 H& [: }! q- M
  33.                 "name='" + name + '\'' +  
    $ d9 E/ C" y1 U2 r& V+ x1 ~- v
  34.                 ", chatContent='" + chatContent + '\'' +  + x" G/ u5 }) k8 c4 n: E+ ^
  35.                 ", coordinationId='" + coordinationId + '\'' +  
    : x/ a  S2 D/ N* Q5 K
  36.                 '}';  
      U: [1 X8 n$ s9 c$ N8 E
  37.     }  6 v: \7 R& Q* K
  38. }  
    : p; J) _5 ~, S8 m4 [0 X! k
  39. </font>
复制代码
4 B, k4 `) K: Y+ \+ U. K: x* n5 _
通过这个bean来接收到web端发送的消息,然后在服务端转发,接下来就是转发的逻辑了,不过首先需要介绍一下Spring WebSocket的一个annotation。
& r/ `, H1 _( I) T9 _7 G& Vspring mvc的controller层的annotation是RequestMapping大家都知道,同样的,WebSocket也有同样功能的annotation,就是MessageMapping,其值就是访问地址。现在就来看看controller层是怎么实现的吧:
  h3 X2 S! c& q3 k' i- Q
  1. <font color="#000000">
    ( B- ?* C9 m, i  o' @3 d2 C7 W) B
  2. /**8 Y: d2 h# G: v8 W0 c7 v0 Z0 T
  3. * WebSocket聊天的相应接收方法和转发方法
    9 R. m- M: a# m5 o& b
  4. *
    0 V+ }7 \9 z6 m$ I4 d) b
  5. * @param userChat 关于用户聊天的各个信息3 l, _6 C) K+ k
  6. */  ! r9 H7 B! m8 q  H, t# Q
  7. @MessageMapping("/userChat")  : D- E: z6 T! G- ?  H6 E  T% k
  8. public void userChat(UserChatCommand userChat) {  / w) I- U$ k: t$ A9 ~4 b
  9.     //找到需要发送的地址  6 E1 {0 l* f# @9 v+ G8 h3 b
  10.     String dest = "/userChat/chat" + userChat.getCoordinationId();  
    8 R5 u9 @5 A8 S1 @2 s" `2 l
  11.     //发送用户的聊天记录  
    ; n. \/ H" K, D
  12.     this.template.convertAndSend(dest, userChat);  
    6 u8 @" T6 J5 j# ?" _4 N
  13. }  </font>
复制代码
3 E9 X  t& S$ k- p7 L' M

- N* g3 o7 F5 n, `8 x1 g' f怎么这么简单?呵呵,能够这么简单的实现后台代码,全是Spring的功劳。首先,我们约定好发送地址的规则,就是chat后面跟上之前发送过来的id,然后通过这个“template”来进行转发,这个“template”是Spring实现的一个发送模板类:SimpMessagingTemplate,在我们定义controller的时候,可以在构造方法中进行注入:
  1. <font color="#000000">0 ?3 h' J0 j5 Z8 J  I# G
  2. ( G0 a: n& l9 d4 N
  3. @Controller  
    & P/ @! a, x  F! `. r) a6 h: }! o) |
  4. public class CoordinationController {  8 v. |1 u0 x6 t% H
  5.   ; v1 h8 m  ]7 ~/ x5 x) f
  6.     ......  % z$ ]! ^- z$ Q; d8 k. r" ~, V( G
  7.   # h& Q+ q2 X1 w  {1 u
  8.     //用于转发数据(sendTo)  
    : n- M0 `8 X) |2 @. K3 }2 q; l
  9.     private SimpMessagingTemplate template;  1 T2 u( h' c0 u# A3 w
  10.     <pre name="code" class="java">    @Autowired  5 P9 B+ _9 E; n, |; y8 l, k; e
  11.     public CoordinationController(SimpMessagingTemplate t) {  
    3 Q/ B2 V2 w% F- Q) [
  12.         template = t;  ! D( h2 ]* ?: q5 f1 t
  13.     }  $ Y- Y( E  @" @" @
  14.     .....  
    5 [4 A) b% ~* U0 I% U( E8 ^
  15. }</font>
复制代码
现在就已经将用户发送过来的聊天信息转发到了一个约定的空间内,只要web端的用户订阅的是这个空间的地址,那么就会收到转发过来的json。现在来看看web端需要做什么吧。4 i% F2 p( K9 e  e# l
第三步:Web端代码$ }  \9 D6 w3 Z; n
上一篇文章中已经介绍过了连接WebSocket,所以这里就不重复的说了。8 l3 _$ M  \4 q! s. ~8 k
首先我们创建一个页面,在页面中写一个textarea(id=chat_content)用来当做聊天记录显示的地方,写一个input(id=chat_input)当做聊天框,写一个button当做发送按钮,虽然简陋了点,页面的美化留到功能实现之后吧。- h) V6 H0 n! @4 |$ w% m, a' y
现在要用到上一篇文章中用于连接后台的stompClient了,将这个stompClient定义为全局变量,以方便我们在任何地方使用它。按照逻辑,我们先写一个发送消息的方法,这样可以首先测试后台是不是正确。
2 ~$ [9 y$ h1 Y( E/ t& ?; w" N我们写一个function叫sendName(写代码的时候乱取的),并且绑定到发送按钮onclick事件。我们要做的事情大概是以下几步:$ ^2 j6 u6 i; W" ~
1.获取input
# ~/ K1 n# T1 E! J4 S& Z9 A0 c9 v# @2.所需要的数据组装一个string
+ h& |9 a# i. I5 i' f5 I3.发送到后台
' x8 [6 V+ R1 F" y, A% Q+ k第一步很简单,使用jquery一秒搞定,第二步可以使用JSON.stringify()方法搞定,第三步就要用到stompClient的send方法了,send方法有三个参数,第一个是发送的地址,第二个参数是头信息,第三个参数是消息体,所以sendName的整体代码如下:
6 o, X! l+ B! W
  1. <font color="#000000">/ T" p  ^4 U3 t( i
  2. //发送聊天信息  + V" F7 I; @7 N7 E# \4 q
  3. function sendName() {  + |  @) }) l8 V+ S7 [* X, `- ^
  4.     var input = $('#chat_input');  
    " E- e" q% i. e+ y
  5.     var inputValue = input.val();  
    6 D! b* `- w7 Q; f8 ^
  6.     input.val("");  
    8 ~& ?& O8 d6 A% X
  7.     stompClient.send("/app/userChat", {}, JSON.stringify({  
    # i. z6 ?# U+ c' G
  8.         'name': encodeURIComponent(name),  
    + S3 W1 L, U: i2 g, Z
  9.         'chatContent': encodeURIComponent(inputValue),  3 w6 X$ k, Z8 _- j0 T2 s% ?
  10.         'coordinationId': coordinationId  7 v5 }1 w7 g4 p& R
  11.     }));  
    , b# ^, G: y# c2 p) `# d
  12. }  </font>
复制代码
# E+ a5 R0 C) t" i. t

* Y0 g5 v8 _% P8 O( T) |5 K7 X其中,name和coordinationId是相应的用户信息,可以通过ajax或者jsp获取,这里就不多说了。7 a8 o4 E' u/ Q% z- ^: t/ I* \
解释一下为什么地址是"/app/userChat":# e4 [6 d. Z0 I1 }0 c
在第一篇文章中配置了WebSocket的信息,其中有一项是ApplicationDestinationPrefixes,配置的是"/app",从名字就可以看出,是WebSocket程序地址的前缀,也就是说,其实这个"/app"是为了区别普通地址和WebSocket地址的,所以只要是WebSocket地址,就需要在前面加上"/app",而后台controller地址是"/userChat",所以,最后形成的地址就是"/app/userChat"。3 |" m+ v* ?2 f4 J
现在运行一下程序,在后台下一个断点,我们就可以看到,聊天信息已经发送到了后台。但是web端啥都没有显示,这是因为我们还没有订阅相应的地址,所以后台转发的消息根本就没有去接收。8 b5 f  o: R2 n& I
回到之前连接后台的函数:stompClient.connect('', '', function (frame) {}),可以注意到,最后一个是一个方法体,它是一个回调方法,当连接成功的时候就会调用这个方法,所以我们订阅后台消息就在这个方法体里做。stompClient的订阅方法叫subscribe,有两个参数,第一个参数是订阅的地址,第二个参数是接收到消息时的回调函数。接下来就来尝试订阅聊天信息:
6 ?; g- ?* Z6 i$ v: V4 I% J根据之前的约定,可以得到订阅的地址是'/coordination/coordination' + coordinationId,所以我们订阅这个地址就可以了,当订阅成功后,只要后台有转发消息,就会调用第二个方法,并且,将后台传过来的消息体作为参数。所以订阅的方法如下:
( A) m5 G# A/ F6 t1 P
  1. <font color="#000000">  y  f, v' J8 F( P3 x, k, h4 G: w
  2. <font size="4">//用户聊天订阅  
    $ X1 h* `* O3 \+ g, q
  3. stompClient.subscribe('/userChat/chat' + coordinationId, function (chat) {  
    5 H  h  p) A6 n( {3 ]2 L' {
  4.     showChat(JSON.parse(chat.body));    |. A9 Q& [5 r+ s8 b
  5. });  </font></font>
复制代码

  f$ r# A! h2 }4 o7 i, p将消息体转为json,再写一个显示聊天信息的方法就可以了,显示聊天信息的方法不再解释,如下:
0 S8 p: d5 r# ^" z7 Z
  1. <font color="#000000"><font size="4">9 `, |( N  |/ f' |
  2. //显示聊天信息  ; R! h2 R) Y0 y" O8 Q8 j
  3. function showChat(message) {  
    % v/ t, j0 k6 c  U( `
  4.     var response = document.getElementById('chat_content');  
    8 r; m9 `/ e6 p2 r: ^7 c  @
  5.     response.value += decodeURIComponent(message.name) + ':' + decodeURIComponent(message.chatContent) + '\n';  ( R1 }( ?, t* I1 j
  6. }  </font></font>
复制代码
- v0 \. y( I2 e0 g) D; u
因为之前处理中文问题,所以发到后台的数据是转码了的,从后台发回来之后,也需要将编码转回来。
* ]% V& J' Y0 z& M( o7 g到这里,聊天功能就已经做完了,运行程序,会发现,真的可以聊天了!一个聊天程序,就是这么简单。
& T0 ^9 D- A8 v, I: v但是这样并不能满足,往后的功能可以发挥我们的想象力来添加,比如说:我觉得,聊天程序,至少也要缓存一些聊天记录,不然后进来的用户都不知道之前的用户在聊什么,用户体验会非常不好,接下来就看看聊天记录的缓存是怎么实现的吧。! V4 F7 J1 a9 B  @, s/ {8 J
第四步:聊天记录缓存实现2 C1 s% g0 J* p0 x  p2 w4 A
由于是一个小程序,就不使用数据库来记录缓存了,这样不仅麻烦,而且效率也低。我简单的使用了一个Map来实现缓存。首先,我们在controller中定义一个Map,这样可以保证在程序运行的时候,只有一个缓存副本。Map的键是每个空间的id,值是缓存信息。

$ b: q  U2 ]$ i  ^8 W: {
  1. <font color="#000000"><font size="4" face="微软雅黑">private Map<Integer, Object[]> coordinationCache = new HashMap<Integer, Object[]>();  </font></font>
复制代码
1 j0 M3 C# l, j, J2 I7 _/ R/ O
这里我存的是一个Object数组,是因为我写的程序中,除了聊天信息的缓存,还有很多东西要缓存,只是将聊天信息的缓存放在了这个数组中的一个位置里。0 \% n3 T* W0 X/ l3 \
为了简单起见,可以直接将web端发送过来的UserChatCommand对象存储到缓存里,而我们的服务器资源有限,既然我用Map放到内存中实现缓存,就不会没想到这点,我的想法是实现一个固定大小的队列,当达到队列大小上限的时候,就弹出最先进的元素,再插入要进入的元素,这样就保留了最新的聊天记录。
& U% v0 D2 [2 @! @5 t0 D- D但是貌似没有这样的队列(我反正没在jdk中看到),所以我就自己实现了这样的一个队列,实现非常的简单,类名叫LimitQueue,使用泛型,继承自Queue,类中定义两个成员变量:
$ F: w5 i; l( \9 u8 b) b6 p
  1. <font color="#000000">
      m0 @& l3 l) h4 j! c8 q
  2. <font face="微软雅黑" size="4">private int limit;  5 }0 b9 n/ A" \5 p+ ?3 M8 Y
  3. private Queue<E> queue;  </font></font>
复制代码
) ]3 x/ l" e% f( y7 T

! c. _9 h0 ]9 ?( p4 \limit代表队列的上限,queue是真正使用的队列。创建一个由这两个参数形成的构造方法,并且实现Queue的所有方法,所有的方法都由queue对象去完成,比如:
- x( E. M1 s2 o5 n$ K
  1. <font color="#000000"><font face="微软雅黑" size="4">0 M( q/ C+ ]1 [1 r9 S% ?! m, A
  2. @Override  7 Z- V% I1 P9 B3 [
  3. public int size() {  
    7 e) }; Y8 V4 y/ A& ]: S
  4.     return queue.size();  4 R5 u- Z0 r. U% Z
  5. }  " {. r+ \3 j7 H/ e! o5 D7 U7 |
  6.   
    1 P" v  S4 A% G8 P; }+ t
  7. @Override  
    1 Z* Z, w# M  p) Y& B
  8. public boolean isEmpty() {  1 U/ y5 P+ [' P- }! S
  9.     return queue.isEmpty();  ' {6 @3 {+ \: l( W" N
  10. }  </font>. f3 }+ |7 z, L2 [0 M5 X& s5 }
  11. </font>
复制代码
4 S5 ~% _* _% H2 z0 q
其中,有一个方法需要做处理:
( x3 i4 ~2 k2 a
  1. <font color="#000000">$ i% A( y, Y! r
  2. <font face="微软雅黑" size="4">@Override  ' F+ r4 H2 r3 Y+ V
  3. public boolean offer(E e) {  % c# e) A0 `4 X1 S& Z( I' q
  4.     if (queue.size() >= limit) {  2 j4 v# F0 x. M; m9 w
  5.         queue.poll();  
    ( v8 B; A  z5 v" I9 D+ T
  6.     }    l/ ^$ S, c" G/ i+ W/ x( |) Y
  7.     return queue.offer(e);  
    : H- e1 I) i& c' U; Q3 |
  8. }  </font></font>
复制代码

/ H; L$ i- ?. B
  Z* _$ B2 O# U! u$ {% s加入元素的时候,判断是否达到了上限,达到了的话就先出队列,再入队列。这样,就实现了固定大小的队列,并且总是保持最新的记录。) x7 A8 K+ X# K8 J# x: _
然后,在web端发送聊天消息到后台的时候,就可以将消息记录在这个队列中,保存在Map里,所以更改之后的聊天接收方法如下:
    1. /**
      3 X( z/ n$ w. J
    2.      * WebSocket聊天的相应接收方法和转发方法
      ) i7 {0 m5 z* @4 B
    3.      *
      $ W+ j; D" P( t, n, T) @
    4.      * @param userChat 关于用户聊天的各个信息, Z; r$ Z- y% m; X
    5.      */  5 d- }( h- R3 r; F- v; v
    6.     @MessageMapping("/userChat")  
      ( K$ e. Q# p: N
    7.     public void userChat(UserChatCommand userChat) {  
      ; ^1 f, k1 y7 w- ?, H/ i& A
    8.         //找到需要发送的地址  
      * w/ l# B- T' D
    9.         String dest = "/userChat/chat" + userChat.getCoordinationId();  5 O6 g5 q1 z* v1 x1 ~5 c1 f
    10.         //发送用户的聊天记录  7 q# N2 d) V2 Y' k
    11.         this.template.convertAndSend(dest, userChat);  
      4 u& W3 m4 a2 {7 w
    12.         //获取缓存,并将用户最新的聊天记录存储到缓存中  ' v$ l+ e2 m, }8 E7 a& Z% K5 L
    13.         Object[] cache = coordinationCache.get(Integer.parseInt(userChat.getCoordinationId()));  
      2 C/ \9 ^' g9 ], P2 _0 L8 J2 q" x
    14.         try {  
      ) f9 L: G2 V- d
    15.             userChat.setName(URLDecoder.decode(userChat.getName(), "utf-8"));  
      , b. v& K) v0 Q2 Q$ y" y
    16.             userChat.setChatContent(URLDecoder.decode(userChat.getChatContent(), "utf-8"));  : J8 w4 Y. _. q$ {$ L5 Z
    17.         } catch (UnsupportedEncodingException e) {  
        ?1 ~$ V: X  `
    18.             e.printStackTrace();  
      & b/ @7 {* m8 D8 P5 A
    19.         }  ! @" F$ B& k1 r! T" U
    20.         ((LimitQueue<UserChatCommand>) cache[1]).offer(userChat);  
      * }# R3 N9 O: F& |
    21.     }  
    复制代码

    & L/ z/ M2 ?, D9 k

( W; P6 Q6 e; P! r/ F- Z已经有缓存了,只要在页面上取出缓存就能显示聊天记录了,可以通过ajax或者jsp等方法,不过,WebSocket也有方法可以实现,因为Spring WebSocket提供了一个叫SubscribeMapping的annotation,这个annotation标记的方法,是在订阅的时候调用的,也就是说,基本是只执行一次的方法,很适合我们来初始化聊天记录。所以,在订阅聊天信息的代码下面,可以增加一个初始化聊天记录的方法。我们先写好web端的代码:
' ]; I! p4 k4 q/ Z. V
    1. ! ~! O# B8 K* f! R- m

    2. 2 J. ]. Y" T, k. X; e2 {0 g* n/ k
    3. //初始化  
      & O' ~' p7 X( F/ t7 N3 m: ]
    4. stompClient.subscribe('/app/init/' + coordinationId, function (initData) {  ' b3 q& T# Y3 C/ u5 ?
    5.     console.log(initData);  
      1 s7 b+ X- h  d3 B" C: x. f' A
    6.     var body = JSON.parse(initData.body);  
      3 c" n8 W0 b( `5 O- O' @
    7.     var chat = body.chat;  7 ~: Q3 P; \5 h# o  p1 U' ]! s
    8.     chat.forEach(function(item) {  . F; w0 D( k0 V) ]. s- q* Q) E7 J" f
    9.         showChat(item);  
      7 b6 E7 c$ n# l1 B
    10.     });  
      % P' @" q8 W" f& \
    11. });  
    复制代码

    - L  F- {, y8 @: x% D- [5 m  y
! C0 _( j" W8 u& ^1 W. B4 l. @2 r
这次订阅的地址是init,还是加上coordinationId来区分空间,发送过来的数据是一个聊天记录的数组,循环显示在对话框中。有了web端代码的约束,后台代码也基本出来了,只要使用SubscribeMapping,再组装一下数据就完成了,后台代码如下:

! D' P/ V. O. I" U& H2 g
  1. <font color="#000000">! `: z; q8 H) ~) b

  2. 5 o/ F5 T: t/ {, r1 J6 Y1 |- |
  3. <font face="微软雅黑" size="4">/**
    ; |& C) u5 K+ Q' V
  4.      * 初始化,初始化聊天记录  p) E: i; T( P
  5.      *
    : ~2 p& }1 l0 g. c  u1 v) n5 g/ m
  6.      * @param coordinationId 协同空间的id
    0 `6 n$ q  R9 m6 l8 r
  7.      */  
    5 w: H# S0 ~6 t" g( J
  8.     @SubscribeMapping("/init/{coordinationId}")  
    2 K3 [( S' e4 B$ p; }
  9.     public Map<String,Object> init(@DestinationVariable("coordinationId") int coordinationId) {  2 p$ u7 M  t( K1 e7 E+ x' }
  10.         System.out.println("------------新用户进入,空间初始化---------");  # B2 O! j! K7 t" r, v& @. F
  11.         Map<String, Object> document = new HashMap<String, Object>();  $ ~+ D0 E& t! S$ u  R9 M
  12.         document.put("chat",coordinationCache.get(coordinationId)[1]);  
    % q1 ?' S( r  N& v# t
  13.         return document;  # j8 y6 S8 |8 F' A
  14.     }  </font></font>
复制代码

; t9 d1 S& I/ K2 m& }# O# O! @% `$ T* v$ z" E& w9 v0 L
就这样,缓存聊天记录也实现了。! C& ?( x( E2 y4 u$ @

  m  |" N& l: {9 E0 {: A; I结语7 `- Y: u, r6 u7 Q/ o+ i8 [  {
这是我的毕业设计,我的毕业设计是一个在线协同备课系统,用于多人在线同时且实时操作文档和演示文稿,其中包含了聊天这个小功能,所以使用它来讲解一下Spring WebSocket的使用。
. z' }5 F1 p: R) W/ ?$ H/ `/ Z我将代码放到了github上,有兴趣的朋友可以去看看代码,接下来,我会考虑将我的毕业设计的源码介绍一下,其中有很多不足,也希望大家指正。6 D& V& }9 H- C1 R5 l
github地址:https://github.com/xjyaikj/OnlinePreparation. D; }( J1 g5 ?2 S; m& R; I

& Q9 q: ?) P" \5 \; @/ p" M$ ^
) v" A% z9 _; R! [# {
*滑动验证:
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

qq
收缩
快速回复 返回顶部 返回列表