Nginx 配置WSS 解析与实战
1. 几个关键概念
1.1 WebSocket
WebSocket 协议是 html5 的一种通信协议,该协议兼容我们常用的浏览器。例如:Chrome、Firefox、IE 等。它可以使客户端和服务端双向数据传输更加简单快捷,并且在TCP连接进行一次握手后,就可以持久性连接,同时允许服务端对客户端推送数据。外加传统模式的协议一般HTTP轻轻可能会包含较长的头部,但真正有效的可能只有小部分,从而占用了很多资源和宽带。因此WebSocket协议不仅可以实时通讯,支持扩展;也可以压缩节省服务资源和宽带。 关于WebSocket的深入理解,可以看这篇文章:Html5 WebSocket 。
WS 协议 和 WSS 协议两个均是 WebSocket 协议的 SCHEM,两者一个是非安全的,一个是安全的。也是统一的资源标志符。就好比 HTTP协议 和 HTTPS协议的差别。非安全的没有证书,安全的需要 SSL 证书。其中 WSS 表示在 TLS 之上的 WebSocket。
WS 一般默认是 80 端口,而 WSS 默认是 443 端口,大多数网站用的就是 80 和 443 端口。
1.2 SSL
SSL(Secure Socket Layer,安全套接层) 简单来说是一种加密技术, 通过它, 我们可以在通信的双方上建立一个安全的通信链路, 因此数据交互的双方可以安全地通信, 而不需要担心数据被窃取. 关于 SSL 的深入知识, 可以看这篇文章: SSL/TLS协议运行机制的概述。
1.3 WSS
WSS 是 Web Socket Secure 的简称, 它是 WebSocket 的加密版本. 我们知道 WebSocket 中的数据是不加密的, 但是不加密的数据很容易被别有用心的人窃取, 因此为了保护数据安全, 人们将 WebSocket 与 SSL 结合, 实现了安全的 WebSocket 通信, 即 WebSocket Secure。所以说 WSS 是使用 SSL 进行加密了的 WebSocket 通信技术。
1.3 HTTPS
其实 HTTPS 和 WSS 类似, HTTP 之于 HTTPS 就像 WebSocket 之于 WebSocket Secure。
HTTP 协议本身也是明文传输, 因此为了数据的安全性, 人们利用 SSL 作为加密通道, 在 SSL 之上传递 HTTP 数据, 因此 SSL 加密通道上运行的 HTTP 协议就被称为 HTTPS 了。
1.4 总结
SSL 是基础, 在 SSL 上运行 WebSocket 协议就是 WSS; 在 SSL 上运行 HTTP 协议就是 HTTPS。
2. SSL单向/双向认证
2.1 单向认证 SSL 协议的具体过程
- 客户端的浏览器向服务器传送客户端 SSL 协议的版本号,加密算法的种类,产生的随机数,以及其他服务器和客户端之间通讯所需要的各种信息。
- 服务器向客户端传送 SSL 协议的版本号,加密算法的种类,随机数以及其他相关信息,同时服务器还将向客户端传送自己的证书。
- 客户利用服务器传过来的信息验证服务器的合法性,服务器的合法性包括:证书是否过期,发行服务器证书的 CA 是否可靠,发行者证书的公钥能否正确解开服务器证书的“发行者的数字签名”,服务器证书上的域名是否和服务器的实际域名相匹配。如果合法性验证没有通过, 通讯将断开;如果合法性验证通过,将继续进行第四步。
- 用户端随机产生一个用于后面通讯的“对称密码”,然后用服务器的公钥(服务器的公钥从步骤②中的服务器的证书中获得)对其加密,然后将加密后的“预主密码”传给服务器。
- 如果服务器要求客户的身份认证(在握手过程中为可选),用户可以建立一个随机数然后对其进行数据签名,将这个含有签名的随机数和客户自己的证书以及加密过的“预主密码”一起传给服务器。
- 如果服务器要求客户的身份认证,服务器必须检验客户证书和签名随机数的合法性,具体的合法性验证过程包括:客户的证书使用日期是否有效,为客户提供证书的 CA 是否可靠,发行CA 的公钥能否正确解开客户证书的发行 CA 的数字签名,检查客户的证书是否在证书废止列表(CRL)中。检验如果没有通过,通讯立刻中断;如果验证通过,服务器将用自己的私钥解开加密的“预主密码 ”,然后执行一系列步骤来产生主通讯密码(客户端也将通过同样的方法产生相同的主通讯密码)。
- 服务器和客户端用相同的主密码即“通话密码”,一个对称密钥用于 SSL 协议的安全数据通讯的加解密通讯。同时在 SSL 通讯过程中还要完成数据通讯的完整性,防止数据通讯中的任何变化。
- 客户端向服务器端发出信息,指明后面的数据通讯将使用的步骤⑦中的主密码为对称密钥,同时通知服务器客户端的握手过程结束。
- 服务器向客户端发出信息,指明后面的数据通讯将使用的步骤⑦中的主密码为对称密钥,同时通知客户端服务器端的握手过程结束。
- SSL 的握手部分结束,SSL 安全通道的数据通讯开始,客户和服务器开始使用相同的对称密钥进行数据通讯,同时进行通讯完整性的检验。
2.2 双向认证 SSL 协议的具体过程
- 浏览器发送一个连接请求给安全服务器。
- 服务器将自己的证书,以及同证书相关的信息发送给客户浏览器。
- 客户浏览器检查服务器送过来的证书是否是由自己信赖的 CA 中心所签发的。如果是,就继续执行协议;如果不是,客户浏览器就给客户一个警告消息:警告客户这个证书不是可以信赖的,询问客户是否需要继续。
- 接着客户浏览器比较证书里的消息,例如域名和公钥,与服务器刚刚发送的相关消息是否一致,如果是一致的,客户浏览器认可这个服务器的合法身份。
- 服务器要求客户发送客户自己的证书。收到后,服务器验证客户的证书,如果没有通过验证,拒绝连接;如果通过验证,服务器获得用户的公钥。
- 客户浏览器告诉服务器自己所能够支持的通讯对称密码方案。
- 服务器从客户发送过来的密码方案中,选择一种加密程度最高的密码方案,用客户的公钥加过密后通知浏览器。
- 浏览器针对这个密码方案,选择一个通话密钥,接着用服务器的公钥加过密后发送给服务器。
- 服务器接收到浏览器送过来的消息,用自己的私钥解密,获得通话密钥。
- 服务器、浏览器接下来的通讯都是用对称密码方案,对称密钥是加过密的。
上面所述的是双向认证 SSL 协议的具体通讯过程,这种情况要求服务器和用户双方都有证书。
单向认证 SSL 协议不需要客户拥有 CA 证书,具体的过程相对于上面的步骤,只需将服务器端验证客户证书的过程去掉,以及在协商对称密码方案,对称通话密钥时,服务器发送给客户的是没有加过密的 (这并不影响 SSL 过程的安全性)密码方案。这样,双方具体的通讯内容,就是加过密的数据,如果有第三方攻击,获得的只是加密的数据,第三方要获得有用的信息,就需要对加密 的数据进行解密,这时候的安全就依赖于密码方案的安全。而幸运的是,目前所用的密码方案,只要通讯密钥长度足够的长,就足够的安全。这也是我们强调要求使 用 128 位加密通讯的原因。
3. 证书格式说明
在使用openssl自己生成证书的时候,会发现网上很多例子生成的证书格式都不同,同一篇文章里也会有很多种格式.
所以就需要了解下 不同格式有什么区别和联系
参考博客http://blog.csdn.net/justinjing0612/article/details/7770301
参考博客http://www.cnblogs.com/lzjsky/archive/2010/11/14/1877143.html
- der,cer文件一般是二进制格式的,只放证书,不含私钥
- crt文件可能是二进制的,也可能是文本格式的,应该以文本格式居多,功能同der/cer
- pem文件一般是文本格式的,可以放证书或者私钥,或者两者都有
- pem如果只含私钥的话,一般用.key扩展名,而且可以有密码保护
- pfx,p12文件是二进制格式,同时含私钥和证书,通常有保护密码
4. 使用openssl生成证书
因为我的系统是Mac,这里记录的是Mac申请自签名的SSL证书,其他系统也类似,这里不再赘述。
- 桌面创建"SSL"文件夹,用来存放申请证书过程中的所有文件。
- 打开"终端"并进入到"桌面->SSL"。
- 管理员权限(命令:sudo su)。
这个命令超级重要!重要!重要!没有在管理员权限下执行下面的操作,即使你可以完成前面几步,也会在最后一步卡住报错!
sudo su
- 创建rootCA.key(命令:openssl genrsa -des3 -out rootCA.key 2048)。
创建时需要输入一个密码,这个密码要记住,下一步有用。
openssl genrsa -des3 -out rootCA.key 2048
- 使用rootCA.key创建rootCA.pem(命令:openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 -out rootCA.pem)
创建时需要输入rootCA.key的密码。
其他信息可以随意填写。
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 -out rootCA.pem
- 双击rootCA.pem证书。在钥匙串中双击,并修改为"始终信任"该证书
- 创建v3.ext文件(命令:touch v3.ext)
touch v3.ext
8、编辑v3.ext文件(命令:sudo vim v3.ext)
sudo vim v3.ext
将下列文字复制粘贴进去
注意最后一行,(DNS.1 = xxxxxxx)
这一行可以输入域名或IP。
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName=@alt_names
[alt_names]
DNS.1 = 192.168.0.3
- 创建server.csr和server.key(命令:openssl req -new -sha256 -nodes -out server.csr -newkey rsa:2048 -keyout server.key)
这里要注意倒数第二项,必须填自己的IP地址或域名,如果IP为动态获取的,建议先改成固定IP。
openssl req -new -sha256 -nodes -out server.csr -newkey rsa:2048 -keyout server.key
- 创建server.crt(命令:openssl x509 -req -in server.csr -CA [rootCA.pem路径] -CAkey [rootCA.key路径] -CAcreateserial -out server.crt -days 500 -sha256 -extfile v3.ext)
去掉中括号,把对应文件路径写进去。
如果你报了“Error opening CA Certificate”这个错误,请先检查路径是否正确,如果正确请检查是否执行了第三步,我就卡在这卡了一晚上~
如果一切正常会让你输入密码,输入完成后就会生成server.crt!!
openssl x509 -req -in server.csr -CA [rootCA.pem路径] -CAkey [rootCA.key路径] -CAcreateserial -out server.crt -days 500 -sha256 -extfile v3.ext
- 最后查看SSL文件夹
5. Nginx配置 WSS
修改 nginx.conf配置文件
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket {
server 192.168.0.3:8888;
}
server {
listen 8888;
server_name test.com;
ssl on;
ssl_certificate /usr/local/etc/nginx/ZC_chain.crt;
ssl_certificate_key /usr/local/etc/nginx/ZC_key.key;
#ssl_client_certificate /usr/local/etc/nginx/ZC_chain.crt;
ssl_session_timeout 20m;
ssl_verify_client off;
location / {
proxy_pass http://websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
128.190.0.3:8888是真正的服务端地址,nginx所在域名是test.com,代理的端口号是8888,所以前端访问的时候这样配置:
WEBSOCKET_URL: 'wss://test.com:8888',
检查nginx.conf正确性:
nginx -t
重新加载配置文件:
nginx -s reload
6. WebSocketServer(基于SpringBoot)
基于SpringBoot整合WebSocket实现前后端互推消息,该代码可以用于ws协议以及wss协议进行测试。
- 首先创建一个springboot项目,网上教程很多,很简单,最终的目录结构如下:
- 项目的pom.xml如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>webSocket</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>webSocket</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- application.properties中配置端口号。
server.port=8888
- 配置类
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
/**
* ServerEndpointExporter 作用
*
* 这个Bean会自动注册使用@ServerEndpoint注解声明的websocket endpoint
*
* @return
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
- 核心类
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@ServerEndpoint("/webSocket/{sid}")
@Component
public class WebSocketServer {
//静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
private static AtomicInteger onlineNum = new AtomicInteger();
//concurrent包的线程安全Set,用来存放每个客户端对应的WebSocketServer对象。
private static ConcurrentHashMap<String, Session> sessionPools = new ConcurrentHashMap<>();
//发送消息
public void sendMessage(Session session, String message) throws IOException {
if(session != null){
synchronized (session) {
// System.out.println("发送数据:" + message);
session.getBasicRemote().sendText(message);
}
}
}
//给指定用户发送信息
public void sendInfo(String userName, String message){
Session session = sessionPools.get(userName);
try {
sendMessage(session, message);
}catch (Exception e){
e.printStackTrace();
}
}
//建立连接成功调用
@OnOpen
public void onOpen(Session session, @PathParam(value = "sid") String userName){
sessionPools.put(userName, session);
addOnlineCount();
System.out.println(userName + "加入webSocket!当前人数为" + onlineNum);
try {
sendMessage(session, "欢迎" + userName + "加入连接!");
} catch (IOException e) {
e.printStackTrace();
}
}
//关闭连接时调用
@OnClose
public void onClose(@PathParam(value = "sid") String userName){
sessionPools.remove(userName);
subOnlineCount();
System.out.println(userName + "断开webSocket连接!当前人数为" + onlineNum);
}
//收到客户端信息
@OnMessage
public void onMessage(String message) throws IOException{
message = "客户端:" + message + ",已收到";
System.out.println(message);
for (Session session: sessionPools.values()) {
try {
sendMessage(session, message);
} catch(Exception e){
e.printStackTrace();
continue;
}
}
}
//错误时调用
@OnError
public void onError(Session session, Throwable throwable){
System.out.println("发生错误");
throwable.printStackTrace();
}
public static void addOnlineCount(){
onlineNum.incrementAndGet();
}
public static void subOnlineCount() {
onlineNum.decrementAndGet();
}
}
- 在Controller中跳转页面
package com.example.controller;
import com.example.service.WebSocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
@Controller
public class SocketController {
@Autowired
private WebSocketServer webSocketServer;
@RequestMapping("/index")
public String index() {
return "index";
}
@GetMapping("/webSocket")
public ModelAndView socket() {
ModelAndView mav = new ModelAndView("/webSocket");
//mav.addObject("userId", userId);
return mav;
}
}
- 前端代码在webSocket.html中:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket</title>
</head>
<body>
<h3>hello socket</h3>
<p>【userId】:<div><input id="userId" name="userId" type="text" value="10"></div>
<p>【toUserId】:<div><input id="toUserId" name="toUserId" type="text" value="20"></div>
<p>【toUserId】:<div><input id="contentText" name="contentText" type="text" value="hello websocket"></div>
<p>操作:<div><a onclick="openSocket()">开启socket</a></div>
<p>【操作】:<div><a onclick="sendMessage()">发送消息</a></div>
</body>
<script>
var socket;
function openSocket() {
if(typeof(WebSocket) == "undefined") {
console.log("您的浏览器不支持WebSocket");
}else{
console.log("您的浏览器支持WebSocket");
//实现化WebSocket对象,指定要连接的服务器地址与端口 建立连接
var userId = document.getElementById('userId').value;
var socketUrl="wss://192.168.0.3:8888/webSocket/"+userId;
console.log(socketUrl);
if(socket!=null){
socket.close();
socket=null;
}
socket = new WebSocket(socketUrl);
//打开事件
socket.onopen = function() {
console.log("websocket已打开");
//socket.send("这是来自客户端的消息" + location.href + new Date());
};
//获得消息事件
socket.onmessage = function(msg) {
var serverMsg = "收到服务端信息:" + msg.data;
console.log(serverMsg);
//发现消息进入 开始处理前端触发逻辑
};
//关闭事件
socket.onclose = function() {
console.log("websocket已关闭");
};
//发生了错误事件
socket.onerror = function() {
console.log("websocket发生了错误");
}
}
}
function sendMessage() {
if(typeof(WebSocket) == "undefined") {
console.log("您的浏览器不支持WebSocket");
}else {
// console.log("您的浏览器支持WebSocket");
var toUserId = document.getElementById('toUserId').value;
var contentText = document.getElementById('contentText').value;
var msg = '{"toUserId":"'+toUserId+'","contentText":"'+contentText+'"}';
console.log(msg);
socket.send(msg);
}
}
</script>
</html>
7. WSS + WebSocketClient
- maven依赖
备注:maven仓库官方(https://mvnrepository.com/)
<!-- WebSocket start-->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.3.0</version>
</dependency>
<!-- WebSocket end-->
这个ws客户端对wss支持不好,源码中的wss client 还要使用证书。可以修改为不验证证书,具体看工具类代码。
- 工具类
package com.example.wss;
import java.net.URI;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Map;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.java_websocket.client.DefaultSSLWebSocketClientFactory;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.drafts.Draft;
/**
* <p>
* WSS工具类
* </p>
*
* @author: hejianhui
* @create: 2020-08-23 00:27
* @see MyWssUtil
* @since JDK1.8
*/
abstract class MyWssUtil extends WebSocketClient {
public MyWssUtil(URI serverURI) {
super(serverURI);
if (serverURI.toString().contains("wss://"))
trustAllHosts(this);
}
public MyWssUtil(URI serverURI, Draft draft) {
super(serverURI, draft);
if (serverURI.toString().contains("wss://"))
trustAllHosts(this);
}
public MyWssUtil(URI serverURI, Draft draft, Map<String, String> headers, int connecttimeout) {
super(serverURI, draft, headers, connecttimeout);
if (serverURI.toString().contains("wss://"))
trustAllHosts(this);
}
final static HostnameVerifier DO_NOT_VERIFY = new HostnameVerifier() {
public boolean verify(String hostname, SSLSession session) {
return true;
}
};
static void trustAllHosts(MyWssUtil appClient) {
System.out.println("start...");
TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return new java.security.cert.X509Certificate[]{};
}
@Override
public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
// TODO Auto-generated method stub
}
@Override
public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException {
// TODO Auto-generated method stub
}
}};
try {
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
appClient.setWebSocketFactory(new DefaultSSLWebSocketClientFactory(sc));
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 使用工具进行wss协议的接口请求
package com.example.wss;
import org.java_websocket.WebSocket;
import org.java_websocket.drafts.Draft;
import org.java_websocket.drafts.Draft_17;
import org.java_websocket.handshake.ServerHandshake;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* <p>
* 进行wss协议的接口请求测试
* </p>
*
* @author: hejianhui
* @create: 2020-08-23 00:30
* @see WssTest
* @since JDK1.8
*/
public class WssTest {
public static List<String> result = new ArrayList<>();
public static String initmsg = "{'lat':'118.817891','lng':'31.931724','speed':'0','distance':'0'}";
public static void main(String[] args) throws URISyntaxException {
Map<String, String> headers = new HashMap<>();
// 根据服务端具体进行配置
// headers.put("Sec-WebSocket-Extensions", "permessage-deflate; client_max_window_bits");
// headers.put("Sec-WebSocket-Key", "");
// headers.put("Sec-WebSocket-Protocol", "x-access-token, ");
// headers.put("Sec-WebSocket-Version", "13");
// 以下请求头可以不用传
// headers.put("Connection", "Upgrade");
// headers.put("Upgrade", "websocket");
// headers.put("Accept-Encoding", "gzip, deflate, br");
// headers.put("Accept-Language", "zh-CN,zh;q=0.9");
// headers.put("Cache-Control", "no-cache");
// headers.put("Host", "test.com");
// headers.put("Origin", "https://test.com");
// headers.put("Pragma", "no-cache");
// headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36");
Draft draft = new Draft_17();
draft.setParseMode(WebSocket.Role.CLIENT);
new MyWssUtil(new URI("wss://****************填写自己的url******************")
, draft
, headers
, 10
) {
@Override
public void onClose(int arg0, String arg1, boolean arg2) {
System.out.println(String.format("onClose:【%s】【%s】【%s】", arg0, arg1, arg2));
}
@Override
public void onError(Exception arg0) {
System.out.println(String.format("onError:%s", arg0));
}
@Override
public void onMessage(String arg0) {
if (!arg0.equals("pong")) {
result.add(arg0);
System.out.println(String.format("onMessage:%s", arg0));
}
this.send(arg0);
}
@Override
public void onOpen(ServerHandshake arg0) {
System.out.println(String.format("onOpen:%s", arg0));
this.send(initmsg);
}
}.connect();
}
}
完整代码地址:https://github.com/org-hejianhui/websocket
部分图片来源于网络,版权归原作者,侵删。
以上谢谢大家,求赞求赞求赞!