在java开发中有不少发送Http请求的需求,比较常见的工具类有HttpURLConnection,HttpClient和Spring中的RestTemplate,这几个类封装程度一个比一个高,越来越方便开发,更符合面向对象的编程思想.但是久而久之,也渐渐地忽略了它们背后的HTTP协议和Socket开发.
HTTP
HTTP全称是超文本传输协议(Hyper Text Transfer Protocol),是一个基于 TCP/IP协议,使用请求-响应模型的应用层协议,本质是一种数据格式的约定,它严格规定了方法,头部,数据体等等的位置,具体报文格式如下
- 从请求行的格式可以看出,请求行用空格作为分隔符,这里就解释了为什么URL需要将空格编码成%20,不然就会解析异常
- 从请求头的格式可以看出,请求头是用
\r\n
作为分隔符,所以请求头里面用空格是不需要编码的,请求头的结束标志为\r\n\r\n
- 请求报文的结束位置,是需要看请求头Content-Length,单位是字节,如果大于0,则读取对应长度的字节作为请求体
- 请求报文没有指定ip和端口,因为它是应用层协议,并不是运输层协议,请求头Host虽然不可缺少,但是乱填也是可以的
Socket
前面说到HTTP协议是基于TCP/IP协议的,实际开发中我们并不需要直接执行TCP/IP协议,而是使用Socket,Socket封装了TCP协议中三次握手,四次挥手的复杂过程,对外抽象为connect方法,close方法和stream操作.
先定义请求类SocketHttpRequest和响应类SocketHttpResponse,如下:
@Data
public class SocketHttpRequest {
private String method;
private String host;
private Integer port;
private String url;
private String body;
private MultiValueMap<String, String> headList =new LinkedMultiValueMap<>();
}
@Data
public class SocketHttpResponse {
private String status;
private MultiValueMap<String, String> headList;
private String body;
}
TCP连接是长链接,一次连接的建立,可以多次发送HTTP请求,所以并不能以关闭连接为报文的结束点,而是以HTTP报文格式规定作为开始和结束,其中关键是对\r\n
的插入和解析,在Socket中,数据都是要转成Byte传输的,\r\n
对应的字节分别是13和10,所以发送HTTP报文的代码如下:
public class SocketHttpClient {
Socket socket;
public SocketHttpClient(String host, int port) throws IOException {
socket = new Socket();
socket.connect(new InetSocketAddress(host, port), 2000);
}
public SocketHttpResponse sendRequest(SocketHttpRequest socketHttpRequest) throws IOException {
//写请求
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
StringBuffer sb = new StringBuffer();
//添加地址行
sb.append(socketHttpRequest.getMethod()).append(" ").append(socketHttpRequest.getUrl()).append(" ").append("HTTP/1.1").append("\r\n");
//添加请求头
MultiValueMap<String, String> headMapList = new LinkedMultiValueMap<>();
if (socketHttpRequest.getHeadList() != null) {
headMapList.addAll(socketHttpRequest.getHeadList());
}
headMapList.add(HttpHeaders.HOST, socketHttpRequest.getHost());
if (Strings.isNotBlank(socketHttpRequest.getBody())) {
headMapList.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(socketHttpRequest.getBody().getBytes().length));
}
headMapList.forEach((x, y) -> {
y.forEach(m -> {
sb.append(x + ":" + m + "\r\n");
});
});
sb.append("\r\n"); //代表请求头已经结束
//添加body
if (Strings.isNotBlank(socketHttpRequest.getBody())) {
sb.append(socketHttpRequest.getBody());
}
out.write(sb.toString());
out.flush();
//读取响应
InputStream is = socket.getInputStream();
List<Byte> statusByteList = new ArrayList<>();
List<Byte> headByteList = new ArrayList<>();
List<Byte> bodyByteList = new ArrayList<>();
Integer contentLength = 0;
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
int type = 1;
while (true) {
byte b = (byte) is.read();
if (type == 1) {
statusByteList.add(b);
int size = statusByteList.size();
//是否已经读取完状态行
if (b == 10 && size >= 2 && statusByteList.get(size - 2) == 13) {
type = 2;
}
} else if (type == 2) {
headByteList.add(b);
int size = headByteList.size();
//是否已经将请求头读完
if (b == 10 && size >= 4 && headByteList.get(size - 4) == 13 && headByteList.get(size - 3) == 10 && headByteList.get(size - 2) == 13) {
String head = new String(Bytes.toArray(headByteList));
String[] headArray = head.split("\r\n");
for (String item : headArray) {
String[] itemArray = item.split(":");
multiValueMap.add(itemArray[0].trim(), itemArray[1].trim());
}
type = 3;
Map.Entry<String, List<String>> contentLengthHeadEntry = multiValueMap.entrySet().stream().filter(x -> x.getKey().startsWith(HttpHeaders.CONTENT_LENGTH)).findFirst().orElse(null);
if (contentLengthHeadEntry != null) {
contentLength = Integer.parseInt(contentLengthHeadEntry.getValue().get(0));
if (contentLength == 0) {
break;
}
}
}
} else if (type == 3) {
bodyByteList.add(b);
if (bodyByteList.size() == contentLength) {
break;
}
}
}
String status = new String(Bytes.toArray(statusByteList.subList(0, statusByteList.size() - 2)));
String body = new String(Bytes.toArray(bodyByteList));
SocketHttpResponse socketHttpResponse = new SocketHttpResponse();
socketHttpResponse.setStatus(status);
socketHttpResponse.setHeadList(multiValueMap);
socketHttpResponse.setBody(body);
return socketHttpResponse;
}
public void close() throws IOException {
socket.close();
}
}
笔记
- 上面代码是实验性质的,并不能用于生产环境,只处理Content-Length决定报文结束位置的情况,其实还有响应头为Transfer-Encoding:chunked的情况
- 前面说到,空格是分隔符,需要编码成%20, 事实上不只有空格,URL中的字符需要符合RFC3986规范,编码采用ASCII码,而且表中128个字符只有82个可用,具体代码可以参考一下Tomcat的
HttpParser
类 - 如果请求头中带中文,也要先进行URL编码,然后接收端做解码
- 请求体中可以带中文而不需要任何编码,那是因为发送端在Content-Type中指定了charset=UTF-8,所以接收端能正确地将Byte转成字符串