Spring WebSocket教程(二)

admin 发表于 2017-4-4 22:07:17 | 栏目:站长教程 |分类:[其他教程]
实现目标
5 s; [0 {+ W9 z( m0 j  g# ^这一篇文章,就要直接实现聊天的功能,并且,在聊天功能的基础上,再实现缓存一定聊天记录的功能。) H3 S) I/ W& E4 Y0 U
第一步:聊天实现原理" U1 I( K* {, y
首先,需要明确我们的需求。通常,网页上的聊天,都是聊天室的形式,所以,这个例子也就有了一个聊天的空间的概念,只要在这个空间内,就能够一起聊天。其次,每个人都能够发言,并且被其他的人看到,所以,每个人都会将自己所要说的内容发送到后台,后台转发给每一个人。4 p, w: q+ L: s" ]. w+ X
在客户端,可以用Socket很容易的实现;而在web端,以前都是通过轮询来实现的,但是WebSocket出现之后,就可以通过WebSocket像Socket客户端一样,通过长连接来实现这个功能了。6 ]4 s: w1 v- f0 H8 ~. o( t; `$ d
第二步:服务端基础代码. K# [: N  b0 G& v
通过上面的原理分析可以知道,需要发送到后台的数据很简单,就是用户信息,聊天信息,和所在的空间信息,因为是一个简单的例子,所以bean就设计的比较简单了:
  1. <font color="#000000">public class UserChatCommand {  , J8 `! l5 B* P9 ^1 P# z$ w" e' T
  2.     private String name;  
    4 z2 N5 p0 t, A; y( P
  3.     private String chatContent;  & i; p2 B5 Y1 B. M  f
  4.     private String coordinationId;  
    " G  @, v% e$ k  A
  5.   
    / j( d0 A" O& {. f  Z
  6.     public String getName() {  
    4 e# D5 x, O( D" ]# U* b
  7.         return name;  / r- |* s; L9 @) j& O6 ?2 X" ]& z. l- n
  8.     }  
    / K$ l; a4 @: C; I
  9.   
    7 d3 w5 m7 ?, a; V) }4 T  Z2 x
  10.     public void setName(String name) {  . t! Y2 o9 W$ d" V% x1 |# L
  11.         this.name = name;  
    , V7 S0 z* T3 X$ Y
  12.     }  * R2 r# [. O+ h( j# o2 k
  13.   
    - Q: R6 \& x; {* f6 Q
  14.     public String getChatContent() {    k& n7 i2 x% d! J4 G0 v  D7 C2 I
  15.         return chatContent;  
    4 [$ J$ ~! a! H* z4 [7 s: Q
  16.     }  5 q0 C5 A  p' P9 t
  17.   2 \% F- R* @, c4 Q! J: z
  18.     public void setChatContent(String chatContent) {  , Y9 d! T6 k' u* R
  19.         this.chatContent = chatContent;  9 m0 l" `; s5 F* d; Q
  20.     }  
    1 W6 X& o5 m3 I. d  r! Z/ a
  21.   
    ! A- W* D/ \- n* S8 N
  22.     public String getCoordinationId() {  # N: y. {# R! R3 [2 Z; B1 Q7 S
  23.         return coordinationId;  
    . w1 {5 G  T% M) q
  24.     }  
    % d) I' A- |0 V! j4 R: m
  25.   , G0 U3 j- A" W. d4 ?
  26.     public void setCoordinationId(String coordinationId) {  ! k% G9 Z- y: x
  27.         this.coordinationId = coordinationId;  
    ' }  c: B5 ^- W" c: q7 C. z4 A
  28.     }  
    - W: o% Z' K$ H1 G
  29.   
    : z( V0 I1 d. T" @4 A- K4 u  A
  30.     @Override  3 b& b4 S. z# j
  31.     public String toString() {  
    ! n( ^7 I& J4 z9 @
  32.         return "UserChatCommand{" +  6 u' z; I8 F% R1 M9 E6 ]) j. d; g
  33.                 "name='" + name + '\'' +  5 m- N7 k+ R# y+ |$ a3 V" [6 n
  34.                 ", chatContent='" + chatContent + '\'' +  
    9 W+ y1 G: \/ l) P! E% ]
  35.                 ", coordinationId='" + coordinationId + '\'' +  + O4 \; a& U5 A* M, H5 n. v
  36.                 '}';  % U% K" q4 y. b% I9 y( R" A& t
  37.     }  + x5 L" X' U5 ]6 w  q1 n3 R
  38. }  
    $ Q/ F* r) E; O5 q' W
  39. </font>
复制代码

8 h( I! i, j- G$ Z  `通过这个bean来接收到web端发送的消息,然后在服务端转发,接下来就是转发的逻辑了,不过首先需要介绍一下Spring WebSocket的一个annotation。
- S2 U1 K3 h7 g5 g/ Ispring mvc的controller层的annotation是RequestMapping大家都知道,同样的,WebSocket也有同样功能的annotation,就是MessageMapping,其值就是访问地址。现在就来看看controller层是怎么实现的吧:

  ]7 g$ x/ l" n/ |' p" F8 g
  1. <font color="#000000">
    # U  ~, G) q/ ~2 B2 c; g% v0 [
  2. /**- v0 B' w* e8 g( F% o
  3. * WebSocket聊天的相应接收方法和转发方法
    $ h, M3 a0 L  }" q" m; p( W" Y
  4. *
    , G0 \; K' p. F' S# j! K7 O+ ^
  5. * @param userChat 关于用户聊天的各个信息
    9 b: ?* m8 l0 f8 \
  6. */  
    5 a3 z# K# J6 H& ]
  7. @MessageMapping("/userChat")  
    , ^6 M! k4 P2 i' s& b
  8. public void userChat(UserChatCommand userChat) {  
    4 v5 s; P* E0 ]' h/ O0 h( K
  9.     //找到需要发送的地址  
    ( `& H! S- d/ ^9 g0 T: I) y1 E+ e
  10.     String dest = "/userChat/chat" + userChat.getCoordinationId();  4 U9 v# E0 D' n% O3 z/ j4 M
  11.     //发送用户的聊天记录  ! F* T0 X: o; L% H; E5 K7 G
  12.     this.template.convertAndSend(dest, userChat);  
    4 r6 j) }+ ^9 U' z& ~4 j, D
  13. }  </font>
复制代码

$ W3 E% s  }' l$ u8 R+ {" I* G& C3 V8 l7 D2 l3 B+ S5 i% `& t* Z
怎么这么简单?呵呵,能够这么简单的实现后台代码,全是Spring的功劳。首先,我们约定好发送地址的规则,就是chat后面跟上之前发送过来的id,然后通过这个“template”来进行转发,这个“template”是Spring实现的一个发送模板类:SimpMessagingTemplate,在我们定义controller的时候,可以在构造方法中进行注入:
  1. <font color="#000000">
    9 o% L. K5 M3 a* a$ |( ]
  2. ! f" {0 b! ~; E& b$ M/ W3 i
  3. @Controller  
    4 Y$ b6 _- N( [. S) b0 n
  4. public class CoordinationController {  
    2 N/ S+ k- w2 |5 `' w' w! q
  5.   2 s$ `# Q5 ]1 V& h) t' S
  6.     ......  
    + C% \8 |$ {! _8 _
  7.   " s6 M% A+ K7 Y8 p: C- K
  8.     //用于转发数据(sendTo)  
    . `2 u$ r3 v1 a! G0 a' R8 k6 i9 I
  9.     private SimpMessagingTemplate template;  
    . y1 s9 v  t" @
  10.     <pre name="code" class="java">    @Autowired  ' M: h8 ^* G0 ^5 y4 x  h$ u
  11.     public CoordinationController(SimpMessagingTemplate t) {  
    % _7 ^' y2 K) {, r) N
  12.         template = t;  9 y% p, z7 S7 s. @; [
  13.     }  
    - Q9 [8 B6 o/ b
  14.     .....  
    ; s8 I$ ]+ S+ m" G* Z: z* @
  15. }</font>
复制代码
现在就已经将用户发送过来的聊天信息转发到了一个约定的空间内,只要web端的用户订阅的是这个空间的地址,那么就会收到转发过来的json。现在来看看web端需要做什么吧。# z! C/ W+ @! V' H7 T9 D3 ~$ w# b
第三步:Web端代码+ v& ~' g$ D' l( P* X
上一篇文章中已经介绍过了连接WebSocket,所以这里就不重复的说了。* M! z/ E, U4 H% ^- r: q1 g$ U
首先我们创建一个页面,在页面中写一个textarea(id=chat_content)用来当做聊天记录显示的地方,写一个input(id=chat_input)当做聊天框,写一个button当做发送按钮,虽然简陋了点,页面的美化留到功能实现之后吧。" l: X8 n" a! d1 u
现在要用到上一篇文章中用于连接后台的stompClient了,将这个stompClient定义为全局变量,以方便我们在任何地方使用它。按照逻辑,我们先写一个发送消息的方法,这样可以首先测试后台是不是正确。1 ?4 h. r) J" C/ ^' I: [1 J4 L
我们写一个function叫sendName(写代码的时候乱取的),并且绑定到发送按钮onclick事件。我们要做的事情大概是以下几步:; ^0 A8 w0 z. ?
1.获取input9 U) g  G4 _1 g5 X9 |
2.所需要的数据组装一个string
' |3 H0 Q  A4 U3 V3.发送到后台
- |5 \' O- c' g: j( ^第一步很简单,使用jquery一秒搞定,第二步可以使用JSON.stringify()方法搞定,第三步就要用到stompClient的send方法了,send方法有三个参数,第一个是发送的地址,第二个参数是头信息,第三个参数是消息体,所以sendName的整体代码如下:
3 _1 k8 G; }; y, ]1 m
  1. <font color="#000000">$ `4 }$ Y. E; F8 [" V! I; [& X
  2. //发送聊天信息  
    ( Q7 O) h; A5 G+ e% g8 k7 N
  3. function sendName() {  
    4 k( \  c9 }4 i; O
  4.     var input = $('#chat_input');  * m$ X$ A9 I# ^% W# O
  5.     var inputValue = input.val();  
    7 U& B1 |9 P# w' a
  6.     input.val("");  # a0 e5 q6 N* S7 U& z0 J. W
  7.     stompClient.send("/app/userChat", {}, JSON.stringify({  
      p+ Z+ \) L4 l* W
  8.         'name': encodeURIComponent(name),  $ {0 f0 S4 v1 s9 d
  9.         'chatContent': encodeURIComponent(inputValue),  
    " c! a$ P9 d1 W; \$ I; C$ ~" K+ t
  10.         'coordinationId': coordinationId  
    ) o8 G- K; I2 m! |/ n, r+ o
  11.     }));  ; E9 z0 U9 S0 P/ x* E0 z( w6 K* F0 P
  12. }  </font>
复制代码
/ \+ Z. `2 o# J# o
" t. e7 \$ T4 I; ]7 N/ T' A
其中,name和coordinationId是相应的用户信息,可以通过ajax或者jsp获取,这里就不多说了。, m+ e# v  ]. n  s5 f
解释一下为什么地址是"/app/userChat":5 f/ \7 \/ s% ^3 |
在第一篇文章中配置了WebSocket的信息,其中有一项是ApplicationDestinationPrefixes,配置的是"/app",从名字就可以看出,是WebSocket程序地址的前缀,也就是说,其实这个"/app"是为了区别普通地址和WebSocket地址的,所以只要是WebSocket地址,就需要在前面加上"/app",而后台controller地址是"/userChat",所以,最后形成的地址就是"/app/userChat"。! P" ]7 j/ R! a" }
现在运行一下程序,在后台下一个断点,我们就可以看到,聊天信息已经发送到了后台。但是web端啥都没有显示,这是因为我们还没有订阅相应的地址,所以后台转发的消息根本就没有去接收。
5 l$ J; M: y+ C  O回到之前连接后台的函数:stompClient.connect('', '', function (frame) {}),可以注意到,最后一个是一个方法体,它是一个回调方法,当连接成功的时候就会调用这个方法,所以我们订阅后台消息就在这个方法体里做。stompClient的订阅方法叫subscribe,有两个参数,第一个参数是订阅的地址,第二个参数是接收到消息时的回调函数。接下来就来尝试订阅聊天信息:
6 r+ S5 }& i# M" {* S7 L' [3 f: N根据之前的约定,可以得到订阅的地址是'/coordination/coordination' + coordinationId,所以我们订阅这个地址就可以了,当订阅成功后,只要后台有转发消息,就会调用第二个方法,并且,将后台传过来的消息体作为参数。所以订阅的方法如下:
* h4 [* H* e+ A+ L1 t
  1. <font color="#000000">
    - L* W+ u2 C( S+ ]
  2. <font size="4">//用户聊天订阅  
    ) m/ c4 }! M* U( O
  3. stompClient.subscribe('/userChat/chat' + coordinationId, function (chat) {  
    : M/ j4 r4 e" ?
  4.     showChat(JSON.parse(chat.body));  
    # a2 q5 p& e& u7 Q& Y
  5. });  </font></font>
复制代码

$ N5 k0 s0 o5 i& a将消息体转为json,再写一个显示聊天信息的方法就可以了,显示聊天信息的方法不再解释,如下:
' N1 b6 R' ~) g8 V3 J
  1. <font color="#000000"><font size="4">
    $ X  `  y) x# B5 ]/ Y
  2. //显示聊天信息  ; R+ Q  y+ X; t* T% v; y
  3. function showChat(message) {  . M- P* a+ i2 |4 z! P
  4.     var response = document.getElementById('chat_content');  
    ) v6 ?3 g, d$ b! W* ]1 ^3 @2 N/ J$ A
  5.     response.value += decodeURIComponent(message.name) + ':' + decodeURIComponent(message.chatContent) + '\n';  
      x( f$ Z  E3 s
  6. }  </font></font>
复制代码
* W4 {" m6 V+ u& B  u0 L/ z- P
因为之前处理中文问题,所以发到后台的数据是转码了的,从后台发回来之后,也需要将编码转回来。- `, e4 L% o4 b1 @6 V8 \# ]. X: [* z; P
到这里,聊天功能就已经做完了,运行程序,会发现,真的可以聊天了!一个聊天程序,就是这么简单。
2 P* \; j$ a& A& N但是这样并不能满足,往后的功能可以发挥我们的想象力来添加,比如说:我觉得,聊天程序,至少也要缓存一些聊天记录,不然后进来的用户都不知道之前的用户在聊什么,用户体验会非常不好,接下来就看看聊天记录的缓存是怎么实现的吧。: e# N. e( ^& M9 R
第四步:聊天记录缓存实现
" e3 ^1 u7 t' f由于是一个小程序,就不使用数据库来记录缓存了,这样不仅麻烦,而且效率也低。我简单的使用了一个Map来实现缓存。首先,我们在controller中定义一个Map,这样可以保证在程序运行的时候,只有一个缓存副本。Map的键是每个空间的id,值是缓存信息。

- [" q+ D. j% c
  1. <font color="#000000"><font size="4" face="微软雅黑">private Map<Integer, Object[]> coordinationCache = new HashMap<Integer, Object[]>();  </font></font>
复制代码

% g$ O4 U7 N- o( t这里我存的是一个Object数组,是因为我写的程序中,除了聊天信息的缓存,还有很多东西要缓存,只是将聊天信息的缓存放在了这个数组中的一个位置里。
8 a# A( ]4 W8 w$ T  t为了简单起见,可以直接将web端发送过来的UserChatCommand对象存储到缓存里,而我们的服务器资源有限,既然我用Map放到内存中实现缓存,就不会没想到这点,我的想法是实现一个固定大小的队列,当达到队列大小上限的时候,就弹出最先进的元素,再插入要进入的元素,这样就保留了最新的聊天记录。
8 E" P' H, V* m9 k& r2 U0 {5 H但是貌似没有这样的队列(我反正没在jdk中看到),所以我就自己实现了这样的一个队列,实现非常的简单,类名叫LimitQueue,使用泛型,继承自Queue,类中定义两个成员变量:
: Q% M$ q3 K+ }
  1. <font color="#000000">2 U+ B3 D, ~$ O! s* P& g& I
  2. <font face="微软雅黑" size="4">private int limit;  
    : g( E. }7 b2 `% t  W$ o
  3. private Queue<E> queue;  </font></font>
复制代码

+ k) o2 @3 R7 |$ x  P7 U3 K. ~, A+ m% H3 P& i$ L
limit代表队列的上限,queue是真正使用的队列。创建一个由这两个参数形成的构造方法,并且实现Queue的所有方法,所有的方法都由queue对象去完成,比如:3 i5 x: z* e% X( E6 N
  1. <font color="#000000"><font face="微软雅黑" size="4">
    7 E" v/ V; j* H4 e3 ?  ~$ s" U
  2. @Override  
    ) n$ H; f) g+ m7 i
  3. public int size() {  
    , H% s, u* `) `! h* d9 ]: X9 Z2 k
  4.     return queue.size();  7 Q; A" ^1 w6 L: L6 J
  5. }  
      I1 b' B3 Q2 u% J* D- f
  6.   
    # X8 z$ t6 p5 D% x4 v* C9 A+ D
  7. @Override  
    / ^" M0 ~2 |  S/ d% t8 G
  8. public boolean isEmpty() {  
    ; A3 q" t) U: ?3 K& P8 W
  9.     return queue.isEmpty();  
    ! f! y) k' a# j. c
  10. }  </font>1 F  R8 |* ], F0 T3 x9 R, q
  11. </font>
复制代码

8 u# c4 o" u( ?其中,有一个方法需要做处理:
- w& t' c% |( d  }
  1. <font color="#000000">3 k/ Z' `9 D' ^3 S; T/ L
  2. <font face="微软雅黑" size="4">@Override  
    ( L, P! X3 V% j8 Y) H
  3. public boolean offer(E e) {  ' Q6 J7 [8 x. ^4 m4 |+ A& z( l
  4.     if (queue.size() >= limit) {  
    - L1 [6 e: X7 c% w, F4 K+ V
  5.         queue.poll();  
    / Q  `$ U5 ]- k5 H
  6.     }  
    $ W% o+ `8 P) V1 L6 ], O
  7.     return queue.offer(e);  - M& ~0 q8 _" u4 t
  8. }  </font></font>
复制代码
8 l2 ]7 D, d; s' S& O
# y1 f1 }$ g$ F. w% g* f
加入元素的时候,判断是否达到了上限,达到了的话就先出队列,再入队列。这样,就实现了固定大小的队列,并且总是保持最新的记录。9 ?  b$ g( U. s. H; R* e4 N! X' l
然后,在web端发送聊天消息到后台的时候,就可以将消息记录在这个队列中,保存在Map里,所以更改之后的聊天接收方法如下:
    1. /**  ~& Q9 }5 v9 V+ t- L
    2.      * WebSocket聊天的相应接收方法和转发方法
      ; I4 x+ @  }7 Q: ^7 }
    3.      *- b1 F+ z; q* L. c/ K/ Z1 d4 g+ N2 |* P
    4.      * @param userChat 关于用户聊天的各个信息
      , X' ]4 g# s5 V, o* w, u
    5.      */  2 X. b# E, X8 h. m% G9 R
    6.     @MessageMapping("/userChat")  + a7 p1 i+ [! s3 E7 X5 W( O
    7.     public void userChat(UserChatCommand userChat) {  2 I! s* ^5 M9 t/ d- S2 v- M6 R7 w8 y
    8.         //找到需要发送的地址    B5 F1 V, ]2 r: k" W5 t" K+ \
    9.         String dest = "/userChat/chat" + userChat.getCoordinationId();  
      - t: x7 y  U, e* |
    10.         //发送用户的聊天记录  
      $ y8 s' L, ~6 A7 u4 {0 f" H% l9 z
    11.         this.template.convertAndSend(dest, userChat);  + H* O& Q3 u& G9 A  Y7 E
    12.         //获取缓存,并将用户最新的聊天记录存储到缓存中  , `% y6 r0 [4 a9 I
    13.         Object[] cache = coordinationCache.get(Integer.parseInt(userChat.getCoordinationId()));  
        Z4 i: V( q& _6 A9 J/ G4 s, q
    14.         try {  9 M4 A$ [  ^& `% i! B
    15.             userChat.setName(URLDecoder.decode(userChat.getName(), "utf-8"));  
      $ m. {3 z9 C% k6 c1 n3 g/ g* q  z
    16.             userChat.setChatContent(URLDecoder.decode(userChat.getChatContent(), "utf-8"));  
      ; a# K: M" {6 n0 p# K% J8 D/ E/ w
    17.         } catch (UnsupportedEncodingException e) {  
      - A4 H. W% m2 T- ?1 D& X
    18.             e.printStackTrace();  " p% H7 s5 R$ K+ U! j* Q
    19.         }  
      4 \6 q5 Q; v; G* Q6 U' P  C
    20.         ((LimitQueue<UserChatCommand>) cache[1]).offer(userChat);  
      9 u. |4 G' f" [" c
    21.     }  
    复制代码

    ! u; a, r8 H7 |" x, O2 S

/ P, L; R, d3 S- y' J已经有缓存了,只要在页面上取出缓存就能显示聊天记录了,可以通过ajax或者jsp等方法,不过,WebSocket也有方法可以实现,因为Spring WebSocket提供了一个叫SubscribeMapping的annotation,这个annotation标记的方法,是在订阅的时候调用的,也就是说,基本是只执行一次的方法,很适合我们来初始化聊天记录。所以,在订阅聊天信息的代码下面,可以增加一个初始化聊天记录的方法。我们先写好web端的代码:' T7 A# p" [- y; l$ Z: M
    1.   Y8 y4 I5 k. K8 f

    2. 5 T: q; O/ T0 J. _1 l" I
    3. //初始化  
      $ u- t5 f' f$ Z+ _7 m* Z
    4. stompClient.subscribe('/app/init/' + coordinationId, function (initData) {  0 A( g/ T/ L# O4 t
    5.     console.log(initData);  
      7 T. h) W% }  }( P5 V& ?
    6.     var body = JSON.parse(initData.body);  7 @5 u, B" u3 B' _
    7.     var chat = body.chat;  3 h$ f; {7 _% o0 {2 T# p- E
    8.     chat.forEach(function(item) {  
      8 L' X9 P* P1 n, W- x+ ?
    9.         showChat(item);  
      ( \  T) s! H5 N0 U  {( N/ C6 X
    10.     });  
      5 g" U, `; Q" c7 ]; x+ g/ U
    11. });  
    复制代码
    1 ?4 m( X6 {: j& }& [
' L/ r, Z6 V! D+ w0 a
这次订阅的地址是init,还是加上coordinationId来区分空间,发送过来的数据是一个聊天记录的数组,循环显示在对话框中。有了web端代码的约束,后台代码也基本出来了,只要使用SubscribeMapping,再组装一下数据就完成了,后台代码如下:
8 w: ?3 d+ X, m( U
  1. <font color="#000000">8 [, i6 \$ Q0 b' p" ]7 o$ T
  2. % k9 F1 |8 U- j6 o) Y9 n. A
  3. <font face="微软雅黑" size="4">/**3 y+ ]* H+ R5 Z  z3 [4 v' l
  4.      * 初始化,初始化聊天记录, |7 B0 v! Z# X  \7 y  i
  5.      *, C# v$ U8 w2 W( \9 `+ @: m- f
  6.      * @param coordinationId 协同空间的id  ]9 V6 c! c8 \+ y& p
  7.      */  
    , R. ~/ ?5 S; I) A8 @2 {0 a! ~/ F
  8.     @SubscribeMapping("/init/{coordinationId}")  3 A0 g4 ?4 ^; |3 p
  9.     public Map<String,Object> init(@DestinationVariable("coordinationId") int coordinationId) {  
    , X7 p7 m( b2 U5 m
  10.         System.out.println("------------新用户进入,空间初始化---------");  
    5 E" l. _% R, E: L# d
  11.         Map<String, Object> document = new HashMap<String, Object>();  ; N8 p& ~& q  N" m; `' q
  12.         document.put("chat",coordinationCache.get(coordinationId)[1]);  
    % m+ N( M0 P" c; B- q: n/ i) s4 y
  13.         return document;  
    $ J3 Z- S% j9 E7 j" t! r. I- Z
  14.     }  </font></font>
复制代码
! L6 \0 `% v8 Y& B* _

# n9 x  U; L4 `3 |+ B就这样,缓存聊天记录也实现了。6 q& G2 @# h& F& z4 h8 b& R

, \' z, D7 F/ ]$ k( \; {/ @结语- k# F- S8 i0 r
这是我的毕业设计,我的毕业设计是一个在线协同备课系统,用于多人在线同时且实时操作文档和演示文稿,其中包含了聊天这个小功能,所以使用它来讲解一下Spring WebSocket的使用。2 c1 a2 m+ ~- c' R4 u- J/ c
我将代码放到了github上,有兴趣的朋友可以去看看代码,接下来,我会考虑将我的毕业设计的源码介绍一下,其中有很多不足,也希望大家指正。
3 V. m; g) ~& Y8 kgithub地址:https://github.com/xjyaikj/OnlinePreparation( W( e: a" `1 ~/ }. }* b
' q- c) x, ^, k7 e& U; C
" t) t# z5 H# q
*滑动验证:
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

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