前言
在你立足处深挖下去,就会有泉水涌出!别管蒙昧者们叫嚷:“下边永远是地狱!”
博客主页:KC老衲爱尼姑的博客主页
博主的github,平常所写代码皆在于此
共勉:talk is cheap, show me the code
作者是爪哇岛的新手,水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!
文章目录
- 模拟实现HTTP服务器
- HTTP服务器
- HTTP服务器版本一
- 创建HttpServer类
- HTTP服务器版本二
- 1.创建HttpRequest类
- 2.创建HttpResponse类
- 3.创建HttpServer类
- HTTP服务器版本三
- 1.创建HttpRequest类
- 2.创建HttpResponse类
- 3.创建HttpServer类
- 实现process()方法
- 实现doGet()方法
- 实现doPost()方法
模拟实现HTTP服务器
HTTP服务器
概述
HTTP服务器本质上也是一种应用程序,通常运行在服务器之上,绑定了服务器的ip地址和某些客户端,这些客户端一般是谷歌,edge,火狐等浏览器。当浏览器发送HTTP请求就可以通过该请求向服务器获得网络资源,而服务器上的HTTP服务器就是解析来自客户端的HTTP请求以及处理HTTP请求。下图就描述的就是这一过程。
HTTP底层是基于TCP实现的,所以接下来模拟实现简单的HTTP服务器使用Java中的TCP编程。
HTTP服务器版本一
在这个版本中,我们只是简单的解析GET请求,并根据请求路径来构造出不同的响应。
创建HttpServer类
- 先初始化 ServerSocket 和 线程池
- 在主循环中循环调用 accept 获取连接. 一旦获取到连接就立刻构造一个任务加入到线程池中. 这个任务负责解析请求并构造响应.
- 在线程池任务中, 先读取请求数据, 按行读取出首行和 header 部分. body 暂时不处理.
- 根据请求的 URL 的路径, 分别构造 “欢迎页面”, “没有找到页面”, 和重定向响应.
示例代码
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HttpServer {
//HTTPs是基于TCP实现的,所以该HTTP依旧按照TCP的基本格式开发
private ServerSocket serverSocket;
/**
*
* @param port 端口
* @throws IOException
*/
public HttpServer(int port) throws IOException {
this.serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService exec = Executors.newCachedThreadPool();
while (true) {
//1.获取连接
Socket clinetSocket = serverSocket.accept();
//处理请求,以短连接方式
exec.execute(new Runnable() {
@Override
public void run() {
process(clinetSocket);
}
});
}
}
/**
* 解析并处理请求
* @param clinetSocket
*/
private void process(Socket clinetSocket) {
//由于HTTP是文本协议,所以用字符流处理
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clinetSocket.getInputStream()));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clinetSocket.getOutputStream()))
){
//下面操作按照HTTP协议来解析
//1.读取请求并解析
String firstLine = reader.readLine();
//HTTP首行是的部分是用空格分隔的
String[] firstLineTokens = firstLine.split(" ");
//得到HTTP请求的类型
String method = firstLineTokens[0];
//得到url
String url = firstLineTokens[1];
//得到HTTP版本
String version = firstLineTokens[2];
//解析header,按行读取,然后按冒号分隔键值对
Map<String,String> headers = new HashMap<String,String>();
String line = "";
//注意:readLine读取的一行内容,是会自动去掉换行,对于空行来说,去掉换行,就变成了空字符串
while ((line = reader.readLine())!=null&&!line.equals("")) {
String [] headerTokens = line.split(": ");
headers.put(headerTokens[0], headerTokens[1]);
}
//解析body,暂时不考虑
//打印内容,看是否正确
System.out.printf("%s %s %s\n",method,url,version);
for (Map.Entry<String,String> entry : headers.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
//处理请求
String body = "";
if (url.equals("/200")) {
writer.write(version+" 200 ok\n");
body = "<h1>Hello World</h1>";
}else if (url.equals("/404")) {
writer.write(version+" 404 ok\n");
body = "<h1>NOT FOUND</h1>";
}else if (url.equals("/302")) {
writer.write(version+" 302 Found\n");
//Location首部指定的是需要将页面重新定向至的地址
writer.write("Location: http://www.bilibili.com\n");
body = "";
}else {
writer.write(version+" 200 ok\n");
body = "<h1>default</h1>";
}
//把响应写会给客户端
writer.write("Content-type: text/html\n");
writer.write("Content-Length: "+body.getBytes().length+"\n");
writer.write("\n");
writer.write(body);
writer.flush();
clinetSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
HttpServer server = new HttpServer(9090);
server.start();
}
}
运行程序, 通过浏览器访问一下 URL进行对程序的测试
- http://127.0.0.1:9090/200
- http://127.0.0.1:9090/404
- http://127.0.0.1:9090/302
- http://127.0.0.1:9090/500
分别访问服务器. 观察效果.
HTTP服务器版本二
在版本1 的基础上, 我们做出一下改进:
- 把解析请求和构造响应的代码提取成单独的类
- 能够把 URL 中的 query string 解析成键值对.
- 能够给浏览器返回 Cookie
1.创建HttpRequest类
- 对照着 HTTP 请求的格式, 创建属性: method, url, version, headers.
- 创建 patameters, 用于存放 query string 的解析结果.
- 创建一个静态方法 build, 用来完成解析 HTTP 请求的过程.
- 从 socket 中读取数据的时候注意设置字符编码方式
- 创建一系列 getter 方法获取到请求中的属性.
- 单独写一个方法 parseKV 用来解析 query string
示例代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
/**
* 表示一个HTTP请求,并负责解析
*/
public class HttpRequest {
private String method;
private String version;
private String url;
private Map<String,String> headers = new HashMap<String,String>();
private Map<String,String> parameters = new HashMap<String,String>();
public static HttpRequest build(InputStream inputStream) throws IOException {
HttpRequest request = new HttpRequest();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream,"UTF-8"));
//1.解析首行
String firstLine = reader.readLine();
String [] firstLineTokens = firstLine.split(" ");
request.method = firstLineTokens[0];
request.url = firstLineTokens[1];
request.version = firstLineTokens[2];
//2.解析url中的参数
int pos = request.url.indexOf("?");
if (pos != -1) {
//获得url中?之后的字符,有字符则解析,没有就不处理
String parameters = request.url.substring(pos + 1);
//切分的结果,key a,value 10
parseKV(parameters,request.parameters);
}
//3.解析header
String line = "";
while ((line = reader.readLine()) != null&&line.length()!=0) {
String [] headerTokens = line.split(": ");
request.headers.put(headerTokens[0], headerTokens[1]);
}
//4.解析body,暂时不考虑
return request;
}
/**
* 将url中的键值对存储到output
* @param parameters
* @param output
* @throws IOException
*/
private static void parseKV(String parameters, Map<String, String> output) throws IOException{
//按&切分成若干组键值对
String [] kvTokens = parameters.split("&");
//针对上述切分结果在按照=进行切分
for (String kvToken : kvTokens) {
String [] result = kvToken.split("=");
output.put(result[0],result[1]);
}
}
public String getMethod() {
return method;
}
public String getVersion() {
return version;
}
public String getUrl() {
return url;
}
public String getHeaders(String key) {
return headers.get(key);
}
public String getParameters(String key) {
return parameters.get(key);
}
@Override
public String toString() {
return "HttpRequest{" +
"method='" + method + '\'' +
", version='" + version + '\'' +
", url='" + url + '\'' +
", headers=" + headers +
", parameters=" + parameters +
'}';
}
}
2.创建HttpResponse类
- 根据 HTTP 响应, 创建属性: version, status, message, headers, body
- 另外创建一个 OutputStream, 用来关联到 Socket 的 OutputStream.
- 往 socket 中写入数据的时候注意指定字符编码方式.
- 创建一个静态方法 build, 用来构造 HttpResponse 对象.
- 创建一系列 setter 方法, 用来设置 HttpResponse 的属性.
- 创建一个 flush 方法, 用于最终把数据写入 OutputStream.
示例代码
import javax.xml.transform.OutputKeys;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
/**
* 负责响应
*/
public class HttpResponse {
/**
* HTTP版本
*/
private String version = "HTTP/1.1";
/**
* 状态码
*/
private int status;
/**
* 状态码的描述信息
*/
private String message;
/**
* header
*/
private Map<String, String> header = new HashMap<String, String>();
/**
* 响应体
*/
private StringBuilder body = new StringBuilder();
/**
* 用于给客户端写数据
*/
private OutputStream outputStream;
public static HttpResponse build(OutputStream outputStream) {
HttpResponse response = new HttpResponse();
response.outputStream = outputStream;
return response;
}
public void setVersion(String version) {
this.version = version;
}
public void setStatus(int status) {
this.status = status;
}
public void setMessage(String message) {
this.message = message;
}
public void setHeader(Map<String, String> header) {
this.header = header;
}
public void writeBody(String content) {
body.append(content);
}
public void flush() throws IOException {
BufferedWriter write = new BufferedWriter(new OutputStreamWriter(outputStream,"UTF-8"));
write.write(version+" "+status+" "+message+"\n");
header.put("Content-Length",body.toString().getBytes().length+"");
for(Map.Entry<String,String> entry:header.entrySet()) {
write.write(entry.getKey()+":"+entry.getValue()+"\n");
}
write.write("\n");
write.write(body.toString());
write.flush();
}
}
3.创建HttpServer类
示例代码
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HttpServer {
private ServerSocket serverSocket;
public HttpServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService exec = Executors.newCachedThreadPool();
while (true) {
Socket clientSocket = serverSocket.accept();
exec.execute(() -> process(clientSocket));
}
}
/**
* 处理请求
*
* @param clientSocket
*/
private void process(Socket clientSocket) {
try {
//1.读取并解析请求
HttpRequest request = HttpRequest.build(clientSocket.getInputStream());
System.out.println("request:" + request);
HttpResponse response = HttpResponse.build(clientSocket.getOutputStream());
//2.根据请求计算响应
if (request.getUrl().startsWith("/200")) {
response.setStatus(200);
response.setMessage("OK");
response.writeBody("<h1>Hello World</h1>");
} else if (request.getUrl().startsWith("/add")) {
String atr = request.getParameters("a");
String btr = request.getParameters("a");
int a = Integer.parseInt(atr);
int b = Integer.parseInt(btr);
int result = a + b;
response.setStatus(200);
response.setMessage("OK");
response.writeBody("<h1>result = " + result + "</h1>");
} else if (request.getUrl().startsWith("/cookieUser")) {
response.setStatus(200);
response.setMessage("OK");
// HTTP 的 header 中允许有多个 Set-Cookie 字段. 但是
// 此处 response 中使用 HashMap 来表示 header 的. 此时相同的 key 就覆盖
response.setHeader("Set-Cookie", "user=zhangsan");
response.writeBody("<h1>set cookieUser</h1>");
} else if (request.getUrl().startsWith("/cookieTime")) {
response.setStatus(200);
response.setMessage("OK");
// HTTP 的 header 中允许有多个 Set-Cookie 字段. 但是
// 此处 response 中使用 HashMap 来表示 header 的. 此时相同的 key 就覆盖
response.setHeader("Set-Cookie", "time=" + (System.currentTimeMillis() / 1000));
response.writeBody("<h1>set cookieTime</h1>");
} else {
response.setStatus(200);
response.setMessage("OK");
response.writeBody("<h1>default</h1>");
}
// 3. 把响应写回到客户端
response.flush();
// 4. 关闭 socket
clientSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
运行程序, 通过浏览器访问一下 URL进行对程序的测试
- http://127.0.0.1:9090/200,验证是否能显示欢迎页面。
- http://127.0.0.1:9090/add?a=10&b=20,验证能否计算出结果。
- http://127.0.0.1:9090/cookieUser,验证浏览器能否获取到 user=zhangsan 这个 Cookie。
- http://127.0.0.1:9090/cookieTime,验证浏览器能否获取到 user=[时间戳] 这个 Cookie。
分别访问服务器. 观察效果.
HTTP服务器版本三
在版本 2 的基础上, 再做出进一步的改进.
- 解析请求中的 Cookie, 解析成键值对.
- body, 按照 x-www-form-urlencoded 的方式解析.
- 根据请求方法, 分别调用 doGet / doPost
- 能够返回指定的静态页面.
- 实现简单的会话机制.
1.创建HttpRequest类
代码整体和 版本2 类似, 做出了以下改变
- 属性中新增了 cookies 和 body
- 新增一个方法 parseCookie, 在解析 header 完成后解析 cookie
- 新增了解析 body 的流程.
实现代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
/**
* 表示一个HTTP请求,并负责解析
*/
public class HttpRequest {
private String method;
private String version;
private String url;
private String body;
private Map<String, String> cookies = new HashMap<String, String>();
private Map<String, String> headers = new HashMap<String, String>();
private Map<String, String> parameters = new HashMap<String, String>();
public static HttpRequest build(InputStream inputStream) throws IOException {
HttpRequest request = new HttpRequest();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
//1.解析首行
String firstLine = reader.readLine();
String[] firstLineTokens = firstLine.split(" ");
request.method = firstLineTokens[0];
request.url = firstLineTokens[1];
request.version = firstLineTokens[2];
//2.解析url中的参数
int pos = request.url.indexOf("?");
if (pos != -1) {
//获得url中?之后的字符,有字符则解析,没有就不处理
String parameters = request.url.substring(pos + 1);
//切分的结果,key a,value 10
parseKV(parameters, request.parameters);
}
//3.解析header
String line = "";
while ((line = reader.readLine()) != null && line.length() != 0) {
String[] headerTokens = line.split(": ");
request.headers.put(headerTokens[0], headerTokens[1]);
}
//4.解析cooike
String cooike = request.headers.get("cooike");
if (cooike != null) {
paseCookie(cooike, request.cookies);
}
//5.解析body
if ("POST".equalsIgnoreCase(request.method) || "PUT".equalsIgnoreCase(request.method)) {
// 需要把 body 读取出来.
// 需要先知道 body 的长度. Content-Length 就是干这个的.
// 此处的长度单位是 "字节"
int contentLength = Integer.parseInt(request.headers.get("Content-Length"));
// 注意体会此处的含义~~
// 例如 contentLength 为 100 , body 中有 100 个字节.
// 下面创建的缓冲区长度是 100 个 char (相当于是 200 个字节)
// 缓冲区不怕长. 就怕不够用. 这样创建的缓冲区才能保证长度管够~~
char[] buffer = new char[contentLength];
int len = reader.read(buffer);
request.body = new String(buffer, 0, len);
// body 中的格式形如: username=tanglaoshi&password=123
parseKV(request.body, request.parameters);
}
return request;
}
private static void paseCookie(String cookie, Map<String, String> cookies) {
// 1. 按照 分号空格 拆分成多个键值对
String[] kvTokens = cookie.split("; ");
// 2. 按照 = 拆分每个键和值
for (String kv : kvTokens) {
String[] result = kv.split("=");
cookies.put(result[0], result[1]);
}
}
/**
* 将url中的键值对存储到output
*
* @param parameters
* @param output
* @throws IOException
*/
private static void parseKV(String parameters, Map<String, String> output) throws IOException {
//按&切分成若干组键值对
String[] kvTokens = parameters.split("&");
//针对上述切分结果在按照=进行切分
for (String kvToken : kvTokens) {
String[] result = kvToken.split("=");
output.put(result[0], result[1]);
}
}
public String getMethod() {
return method;
}
public String getVersion() {
return version;
}
public String getUrl() {
return url;
}
public String getHeaders(String key) {
return headers.get(key);
}
public String getBody() {
return body;
}
public String getParameters(String key) {
return parameters.get(key);
}
public String getCookie(String key) {
return cookies.get(key);
}
@Override
public String toString() {
return "HttpRequest{" +
"method='" + method + '\'' +
", version='" + version + '\'' +
", url='" + url + '\'' +
", headers=" + headers +
", parameters=" + parameters +
'}';
}
}
2.创建HttpResponse类
代码和 版本2 完全一致.
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.Map;
/**
* 负责响应
*/
public class HttpResponse {
/**
* HTTP版本
*/
private String version = "HTTP/1.1";
/**
* 状态码
*/
private int status;
/**
* 状态码的描述信息
*/
private String message;
/**
* header
*/
private Map<String, String> header = new HashMap<String, String>();
/**
* 响应体
*/
private StringBuilder body = new StringBuilder();
/**
* 用于给客户端写数据
*/
private OutputStream outputStream;
public static HttpResponse build(OutputStream outputStream) {
HttpResponse response = new HttpResponse();
response.outputStream = outputStream;
return response;
}
public void setVersion(String version) {
this.version = version;
}
public void setStatus(int status) {
this.status = status;
}
public void setMessage(String message) {
this.message = message;
}
public void setHeader(String key,String value) {
header.put(key,value);
}
public void writeBody(String content) {
body.append(content);
}
public void flush() throws IOException {
BufferedWriter write = new BufferedWriter(new OutputStreamWriter(outputStream,"UTF-8"));
write.write(version+" "+status+" "+message+"\n");
header.put("Content-Length",body.toString().getBytes().length+"");
for(Map.Entry<String,String> entry:header.entrySet()) {
write.write(entry.getKey()+":"+entry.getValue()+"\n");
}
write.write("\n");
write.write(body.toString());
write.flush();
}
}
3.创建HttpServer类
新增一个 sessions 成员, 是一个键值对结构, 用来管理会话. key 是一个字符串. value 是一个 User 对象,User 是用于保存用户信息。
User类
示例代码
/**
* 保存用户的相关信息
*/
public class User {
public String userName;
public int age;
public String school;
}
HttpServer 类
示例代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HttpServer {
private ServerSocket serverSocket;
private HashMap<String, User> sessions = new HashMap<>();
public HttpServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService exec = Executors.newCachedThreadPool();
while (true) {
Socket clientSocket = serverSocket.accept();
exec.execute(() -> process(clientSocket));
}
}
public static void main(String[] args) throws IOException {
HttpServer server = new HttpServer(9090);
server.start();
}
}
实现process()方法
根据请求方法的不同, 分别调用 doGet 和 doPost
/**
* 处理请求
*
* @param clientSocket
*/
private void process(Socket clientSocket) {
// 处理核心逻辑
try {
// 1. 读取请求并解析
HttpRequest request = HttpRequest.build(clientSocket.getInputStream());
HttpResponse response = HttpResponse.build(clientSocket.getOutputStream());
// 2. 根据请求计算响应
// 此处按照不同的 HTTP 方法, 拆分成多个不同的逻辑
if ("GET".equalsIgnoreCase(request.getMethod())) {
doGet(request, response);
} else if ("POST".equalsIgnoreCase(request.getMethod())) {
doPost(request, response);
} else {
// 其他方法, 返回一个 405 这样的状态码
response.setStatus(405);
response.setMessage("Method Not Allowed");
}
// 3. 把响应写回到客户端
response.flush();
// 4. 关闭 socket
clientSocket.close();
} catch (IOException | NullPointerException e) {
e.printStackTrace();
}
}
实现doGet()方法
实现逻辑
- 首先根据请求路径判断用户是否已经登录
- 判断时候登录,先看Cooike是否存在SessionId,再看sessionId是否在sessions中存在
- 如果未登录,则返回一个静态页面index.html,这个页面存放在resourses下
- 通过 HttpServer.class.getClassLoader().getResourceAsStream(“index.html”) 能够打开该文件, 并读取文件内容.
实现代码
private void doGet(HttpRequest request, HttpResponse response) throws IOException {
// 1. 能够支持返回一个 html 文件.
if (request.getUrl().startsWith("/index.html")) {
String sessionId = request.getCookie("sessionId");
User user = sessions.get(sessionId);
if (sessionId == null || user == null) {
response.setStatus(200);
response.setMessage("OK");
response.setHeader("Content-Type", "text/html; charset=utf-8");
InputStream inputStream = HttpServer.class.getClassLoader().getResourceAsStream("index.html");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
// 按行读取内容, 把数据写入到 response 中
String line = null;
while ((line = bufferedReader.readLine()) != null) {
response.writeBody(line + "\n");
}
bufferedReader.close();
} else {
// 用户已经登陆, 无需再登陆了.
}
}
}
实现index.html
通过 form 表单, 通过 POST 提交 username 和 password
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="login" method="POST">
用户名:<input type="text" name="username">
密码:<input type="text" name="password">
<input type="submit" value="提交">
</form>
</body>
</html>
实现doPost()方法
实现逻辑
- 判断路径是否为login
- 获取表单中的用户名和密码对其进行校验
- 如果用户名和密码正确,则返回一个登录成功的页面
- 登录成功的同时,构造出一个SessionId和一个User对象,把这个键值放在sessions中,并把sessionId通过cookie返回给浏览器
- 登录失败,返回一个登录失败的页面
实现代码
private void doPost(HttpRequest request, HttpResponse response) {
// 2. 实现 /login 的处理
if (request.getUrl().startsWith("/login")) {
// 读取用户提交的用户名和密码
String userName = request.getParameters("username");
String password = request.getParameters("password");
System.out.println("userName: " + userName);
System.out.println("password: " + password);
if ("zhangsan".equals(userName) && "123".equals(password)) {
// 登陆成功
response.setStatus(200);
response.setMessage("OK");
response.setHeader("Content-Type", "text/html; charset=utf-8");
String sessionId = UUID.randomUUID().toString();
User user = new User();
user.userName = "zhangsan";
user.age = 20;
user.school = "B站大学";
sessions.put(sessionId, user);
response.setHeader("Set-Cookie", "sessionId=" + sessionId);
response.writeBody("<html>");
response.writeBody("<div>欢迎您! " + userName + "</div>");
response.writeBody("</html>");
} else {
// 登陆失败
response.setStatus(403);
response.setMessage("Forbidden");
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.writeBody("<html>");
response.writeBody("<div>登陆失败</div>");
response.writeBody("</html>");
}
}
}
运行程序. 通过以下 URL 验证: http://127.0.0.1:9090/index.html
(1)首次访问, 当前未登录, 会看到 index.html 这个登陆页面
(2)输入用户名密码之后, 如果登陆成功, 预期看到
(3)后续再访问 http://127.0.0.1:9090/index.html 时, 由于已经登陆过, 不必重新登陆
总结
由此就完成了简单的HTTP服务器,虽然没有Tomcat那么强大,但是还是可以通过上述简陋的程序更好的理解处理HTTP请求的过程。
各位看官如果觉得文章写得不错,点赞评论关注走一波!谢谢啦!。