1、前言
Spring boot创建好后,可以选择自动内嵌Web服务器,并自动starter依赖,简化构建配置,从而简化程序员的工作,专注于业务逻辑本身;
微服务是一种架构风格,一个应用拆分为几组小型服务,各组服务都有自己的进程,也就是可以独立部署与升级,做到去中心化;
Spring boot正是基于微服务的基础上而建立的,我们尝试自己手敲一份与Spring boot类似的底层代码,去模拟实现它的网络服务端口的功能;
2、HTTP协议
在写Spring boot之前,我们首先要了解,浏览器(客户端)与服务端之间的传输是怎么实现的;
HTTP协议:浏览器与服务器通讯的应用层协议,它规定了浏览器与服务器之间的数据格式的交互规则,它有如下定义:
- 要求浏览器与服务端之间必须遵循一问一答的规则,服务端永远不会主动给浏览器发送信息;
- HTTP要求浏览器与服务端的传输协议必须是可靠的,也就是说他们的传输层协议是TCP协议;
TCP协议对于浏览器与服务端之间的传输格式也有要求:
- 定义浏览器给服务端发送的信息称为请求,反之称为响应;
- 请求与响应的内容都是字符串信息,这些字符的字符集是规定好的:ISO-8859-1,这是一个欧洲的字符集,它并不支持中文,也就是说响应与请求里的字符只能是英文、数字与符号;
- 请求:一个请求由三部分组成:
- 请求行 :请求行由三部分组成:请求方式+空格+抽象路径+空格+版本协议+回车换行(CRLF),如:GET /myweb/index.html HTTP/1.1+回车换行,回车换行是不可见符;
- 消息头:消息头是浏览器可以给服务端发送的一些附加信息,有的用来说明浏览器自身内容,有的用来告知服务端交互细节,有的告知服务端消息正文详情等。消息头由若干行组成,每行结束也是以CRLF标志。每个消息头的格式为:消息头的名字+空格+消息的值(CRLF),消息头部分结束是以单独的(CRLF)标志;
- 消息正文:消息正文是2进制数据,通常是用户上传的信息,比如:在页面输入的注册信息,上传的附件等内容;消息正文部分可以没有;
- 响应:一个响应也由三部分组成:
- 状态行:版本协议+空格+状态代码+空格+状态描述,例如:HTTP/1.1 200 OK;有关状态代码:1xx:保留;
2xx:成功,表示处理成功,并正常响应;
3xx:重定向,表示处理成功,但是需要浏览器进一步请求;
4xx:客户端错误,表示客户端请求错误导致服务端无法处理;
5xx:服务端错误,表示服务端处理请求过程出现了错误; - 响应头:响应头与请求行的消息头的格式一致,表示的是服务端发送给客户端的附加信息;响应头有两个主要的内容:Content-Type: 文件类型;Content-Lengrh:文件大小;浏览器接受正文前会根据以上两个响应头来处理响应正文并显示;
- 响应正文:2进制数据部分,通常包含客户端实际请求的资源内容;
3、URL
每一个网页都有一个唯一的名称标识,称之为URL(统一资源定位器),就是我们平常称呼的网址;URL具有全球唯一性,它具有三部分:协议名称、主机地址信息、资源的抽象路径;
协议名称:最常用的是http,网银服务用的是https,用了加密技术,更加安全;
主机地址信息:是指存放资源的服务器的域名系统(DNS) 主机名与IP地址,与协议名称相隔符号“://”
资源的抽象路径:有时可以省略,主要看浏览器会不会发送,与主机地址相隔符号“/”;
4、手写Spring boot
在上个文章中,我们学习了如何创建一个Spring boot项目,里面注入相关的依赖,可以直接往java目录下写业务;这次我们来尝试手写一份代码来实现spring boot 的部分底层功能;
打开idea,新建项目,选择Maven:
起好项目名字后,在目录下可以看到一个pom.xml的文件,在里面可以改变自己需要的依赖;这里我把idea的默认编码改为了UTF-8,在<properties></properties>标签内更改:
<properties>
<!-- 设置 JDK 版本为 1.8 -->
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
<!-- 设置编码为 UTF-8 -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
</properties>
一、创建服务器雏形
浏览器链接服务端,并发送了请求,服务端接收了多个客户端的链接,采用多线程来处理各个客户端的请求:
服务端主类接收多个客户端:
public class WebServerApplication {
private ServerSocket serverSocket;
public WebServerApplication(){
try {
System.out.println("正在启动服务端...");
serverSocket = new ServerSocket(8088);
System.out.println("服务端启动完毕!");
} catch (IOException e) {
e.printStackTrace();
}
}
public void start(){
try {
System.out.println("等待客户端链接...");
Socket socket = serverSocket.accept();
System.out.println("一个客户端链接了!");
//启动一个线程处理与该客户端的交互
ClientHandler handler = new ClientHandler(socket);
Thread t = new Thread(handler);
t.start();
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
WebServerApplication application = new WebServerApplication();
application.start();
}
}
开启多线程处理客户端请求:
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//1解析请求
HttpServletRequest request = new HttpServletRequest(socket);
HttpServletResponse response = new HttpServletResponse(socket);
//2处理请求
DispatcherServlet.getInstance().service(request,response);
//3发送响应
response.response();
} catch (IOException e) {
e.printStackTrace();
} catch (EmptyRequestException e) {
} finally {
//按照HTTP协议要求,一次交互后断开TCP链接
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
从HTTP协议规定可知,每次请求都会有三部分组成:请求行,消息头,消息正文;我们把这些信息一一解析拆分,将解析出来的数据都存入到一个类中:HttpServletResquest
public class HttpServletRequest {
private Socket socket;
//请求行相关信息
private String method; //请求方式
private String uri; //抽象路径
private String protocol;//协议版本
private String requestURI;//保存uri中的请求部分("?"左侧的内容)
private String queryString;//保存uri中的参数部分("?"右侧的内容)
private Map<String, String> parameters = new HashMap<>();//保存每一组参数
//消息头相关信息
private Map<String, String> headers = new HashMap<>();
public HttpServletRequest(Socket socket) throws IOException, EmptyRequestException {
this.socket = socket;
//1.1解析请求行
parseRequestLine();
//1.2:解析消息头
parseHeaders();
//1.3:解析消息正文
parseContent();
}
//解析请求行
private void parseRequestLine() throws IOException, EmptyRequestException {
String line = readLine();
if (line.isEmpty()) {//如果请求行为空字符串,则说明本次为空请求!
throw new EmptyRequestException();
}
System.out.println("请求行:" + line);
String[] data = line.split("\\s");
method = data[0];
uri = data[1];
protocol = data[2];
parseURI();//进一步解析uri
}
//进一步解析uri
private void parseURI() {
/*
uri有两种情况:
1:不含有参数的
例如: /index.html
直接将uri的值赋值给requestURI即可.
2:含有参数的
例如:/regUser?username=fancq&password=&nickname=chuanqi&age=22
将uri中"?"左侧的请求部分赋值给requestURI
将uri中"?"右侧的参数部分赋值给queryString
将参数部分首先按照"&"拆分出每一组参数,再将每一组参数按照"="拆分为参数名与参数值
并将参数名作为key,参数值作为value存入到parameters中。
*/
String[] data = uri.split("\\?");
requestURI = data[0];
if(data.length>1){//说明?后面还有参数
queryString = data[1];
parseParameters(queryString);
}
System.out.println("requestURI:"+requestURI);
System.out.println("queryString:"+queryString);
System.out.println("parameters:"+parameters);
}
/*
解析参数,因为参数GET请求来自抽象路径的"?"右侧,而POST请求来自消息正文,
因为格式一致,所以重用解析操作
*/
private void parseParameters(String line){
//先将参数转换为中文
try {
line = URLDecoder.decode(line,"UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String[] data = line.split("&");//将参数部分按照"&"拆分出每一组参数
for(String para : data){
//para: username=zhangsan
String[] paras = para.split("=");
parameters.put(paras[0],paras.length>1?paras[1]:"");
}
}
//解析消息头
private void parseHeaders() throws IOException {
while (true) {
String line = readLine();
if (line.isEmpty()) {//如果readLine返回空字符串,说明单独读取到了回车+换行
break;
}
System.out.println("消息头:" + line);
/*
将每一个消息头按照": "(冒号+空格拆)分为消息头的名字和消息头的值
并以key,value的形式存入到headers中
*/
String[] data = line.split(":\\s");
//将消息头的名字转换为全小写后存入headers,兼容性更好(浏览器发送的消息头无论大小写,只要拼写正确即可)
headers.put(data[0].toLowerCase(Locale.ROOT), data[1]);
}//while循环结束,消息头解析完毕
System.out.println("headers:" + headers);
}
//解析消息正文
private void parseContent() throws IOException {
//判断本次请求方式是否为post请求
if("POST".equalsIgnoreCase(method)){
//根据消息头Content-Length来确定正文的字节数量以便进行读取
String contentLength = getHeader("Content-Length");
if(contentLength!=null){//判断不为null的目的是确保有消息头Content-Length
int length = Integer.parseInt(contentLength);
System.out.println("正文长度:"+length);
byte[] data = new byte[length];
InputStream in = socket.getInputStream();
in.read(data);
//根据Content-Type来判断正文类型,并进行对应的处理
String contentType = getHeader("Content-Type");
//分支判断不同的类型进行不同的处理
if("application/x-www-form-urlencoded".equals(contentType)){//判断类型是否为form表单不带附件的数据
//该类型的正文就是一行字符串,就是原GET请求提交表单是抽象路径中"?"右侧的参数
String line = new String(data, StandardCharsets.ISO_8859_1);
System.out.println("正文内容:"+line);
parseParameters(line);
}
// else if(){}//扩展其它类型并进行对应的处理
}
}
}
private String readLine() throws IOException {//通常被重用的代码都不自己处理异常
//同一个socket对象无论调用多少次getInputStream()获取的始终是同一个输入流
InputStream in = socket.getInputStream();
int d;//每次读取到的字节
char cur = 'a', pre = 'a';//cur表示本次读取到的字符,pre表示上次读取到的字符
StringBuilder builder = new StringBuilder();
while ((d = in.read()) != -1) {
cur = (char) d;
if (pre == 13 && cur == 10) {//是否已经连续读取到了回车+换行符
break;
}
builder.append(cur);
pre = cur;
}
return builder.toString().trim();
}
public String getMethod() {
return method;
}
public String getUri() {
return uri;
}
public String getProtocol() {
return protocol;
}
public String getHeader(String name) {
/*
headers:
key value
content-type xxx/xxx
*/
return headers.get(name.toLowerCase(Locale.ROOT));
}
public String getRequestURI() {
return requestURI;
}
public String getQueryString() {
return queryString;
}
public String getParameter(String name) {
return parameters.get(name);
}
}
解析完请求后,我们需要对请求做一些基础的判断,以便于服务端发送数据;
在Spring boot里我们会用到注解(Annotation),也叫元数据。一种代码级别的说明。它是JDK1.5及以后版本引入的一个特性,与类、接口、枚举是在同一个层次。它可以声明在包、类、字段、方法、局部变量、方法参数等的前面,用来对这些元素进行说明,注释。注解的详细说明介绍
添加两种自定义注解:Controller与RequestMapping
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
}
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
String value();
}
注解机制是基于反射的作用才会起作用,反射机制是Java的动态机制,可以在程序运行期间再确定实例化对象,方法调用,属性操作等,它可以提高代码的灵活度,但是会带来较多的系统开销与较低的运行效率,因此不能过度使用反射;
反射操作的基础是类对象,在虚拟机加载类时,就会创建一个class实例来与该类绑定。因此每个被加载的类有且只有一个类对象,且该类对象存储了这个类的一切信息;因此反射第一步要做的就是获取类对象:1、类名.class 2、Class.forName(包名.类名);
获取类对象后可以调用方法,来判断该对象是否被注解:
public class ReflectDemo7 {
public static void main(String[] args) throws Exception {
Class cls = Class.forName("reflect.Person");
/*
Class,Method等常用的反射对象都提供了一个方法:
boolean isAnnotationPresent(Class cls)
用于判断是否被参数指定的注解标注过(参数为注解的类对象)
*/
//cls所表示的类Person是否被注解@AutoRunClass标注过
boolean flag = cls.isAnnotationPresent(AutoRunClass.class);
if(flag){
System.out.println("被标注了!");
}else{
System.out.println("没有被标注!");
}
}
}
利用这种方法,我们来创建一个类,对项目目录进行扫描,获取被注解的类以及被注解的方法:
public class HandlerMapping {
private static Map<String, Method> mapping = new HashMap<>();
static{
init();
}
private static void init(){
try {
File dir = new File(
HandlerMapping.class.getClassLoader().getResource(
"./com/webserver/controller"
).toURI()
);
File[] subs = dir.listFiles(f->f.getName().endsWith(".class"));
for(File sub : subs){
String fileName = sub.getName();
String className = fileName.substring(0,fileName.indexOf("."));
className = "com.webserver.controller."+className;
Class cls = Class.forName(className);
if(cls.isAnnotationPresent(Controller.class)){
// Object obj = cls.newInstance();//实例化这个Controller
Method[] methods = cls.getDeclaredMethods();
for(Method method : methods){
method.getDeclaringClass();
if(method.isAnnotationPresent(RequestMapping.class)){
RequestMapping rm = method.getAnnotation(RequestMapping.class);
String value = rm.value();
mapping.put(value,method);
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 根据请求路径获取处理该请求的某Controller的对应方法
* @param path
* @return
*/
public static Method getMethod(String path){
return mapping.get(path);
}
}
Spring MVC有一个核心类DispatcherServlet,用于和底层容器TOMCA整合使用,通过它使得根据请求自动调用对应的方法,或者页面;我们模拟手写一份该类,但忽略了两个框架之间整合的细节,只关心它的业务实现:
public class DispatcherServlet {
private static DispatcherServlet servlet;
private static File rootDir;
private static File staticDir;
static {
servlet = new DispatcherServlet();
try {
//定位到:target/classes
rootDir = new File(
DispatcherServlet.class.getClassLoader().getResource(".").toURI()
);
//定位static目录
staticDir = new File(rootDir, "static");
} catch (URISyntaxException e) {
e.printStackTrace();
}
}
private DispatcherServlet() {
}
public void service(HttpServletRequest request, HttpServletResponse response) throws IOException {
//不能直接使用uri作为请求路径处理了,因为可能包含参数,而参数内容不是固定信息。
String path = request.getRequestURI();
//根据请求路径判断是否为处理某个业务
try {
Method method = HandlerMapping.getMethod(path);//path:/userList
if(method!=null){
method.invoke(method.getDeclaringClass().newInstance(),request,response);
return;
}
} catch (Exception e) {
e.printStackTrace();
}
File file = new File(staticDir, path);
System.out.println("该页面是否存在:" + file.exists());
if (file.isFile()) {//用户请求的资源在static目录下存在且是一个文件
response.setContentFile(file);
} else {
response.setStatusCode(404);
response.setStatusReason("NotFound");
response.setContentFile(new File(staticDir, "/root/404.html"));
}
//测试添加其它响应头
response.addHeader("Server", "WebServer");
}
public static DispatcherServlet getInstance() {
return servlet;
}
}
服务端接收请求后会给客户端做出响应,响应的内容通过类HttpServletResponse来确定:
public class HttpServletResponse {
private Socket socket;
//状态行的相关信息
private int statusCode = 200; //状态代码
private String statusReason = "OK"; //状态描述
//响应头相关信息
private Map<String,String> headers = new HashMap<>(); //响应头
//响应正文相关信息
private File contentFile; //正文对应的实体文件
private ByteArrayOutputStream baos;
public HttpServletResponse(Socket socket){
this.socket = socket;
}
/**
* 将当前响应对象内容以标准的HTTP响应格式,发送给客户端(浏览器)
*/
public void response() throws IOException {
//发送前的准备工作
sendBefore();
//3.1发送状态行
sendStatusLine();
//3.2发送响应头
sendHeaders();
//3.3发送响应正文
sendContent();
}
//发送前的准备工作
private void sendBefore(){
//判断是否有动态数据存在
if(baos!=null){
addHeader("Content-Length",baos.size()+"");
}
}
//发送状态行
private void sendStatusLine() throws IOException {
println("HTTP/1.1" + " " + statusCode + " " +statusReason);
}
//发送响应头
private void sendHeaders() throws IOException {
/*
当发送响应头时,所有待发送的都应当都作为键值对存入了headers中
headers:
key value
Content-Type text/html
Content-Length 245
Server WebServer
... ...
*/
Set<Map.Entry<String,String>> entrySet = headers.entrySet();
for(Map.Entry<String,String> e : entrySet){
String name = e.getKey();
String value = e.getValue();
println(name + ": " + value);
}
//单独发送一组回车+换行表示响应头部分发送完了!
println("");
}
//发送响应正文
private void sendContent() throws IOException {
if(baos!=null){//存在动态数据
byte[] data = baos.toByteArray();
OutputStream out = socket.getOutputStream();
out.write(data);
}else if(contentFile!=null) {
try (
FileInputStream fis = new FileInputStream(contentFile);
) {
OutputStream out = socket.getOutputStream();
int len;
byte[] data = new byte[1024 * 10];
while ((len = fis.read(data)) != -1) {
out.write(data, 0, len);
}
}
}
}
public void sendRedirect(String path){
statusCode = 302;
statusReason = "Moved Temporarily";
addHeader("Location",path);
}
/**
* 向浏览器发送一行字符串(自动补充CR+LF)
* @param line
*/
private void println(String line) throws IOException {
OutputStream out = socket.getOutputStream();
out.write(line.getBytes(StandardCharsets.ISO_8859_1));
out.write(13);//发送回车符
out.write(10);//发送换行符
}
public int getStatusCode() {
return statusCode;
}
public void setStatusCode(int statusCode) {
this.statusCode = statusCode;
}
public String getStatusReason() {
return statusReason;
}
public void setStatusReason(String statusReason) {
this.statusReason = statusReason;
}
public File getContentFile() {
return contentFile;
}
public void setContentFile(File contentFile) throws IOException {
this.contentFile = contentFile;
String contentType = Files.probeContentType(contentFile.toPath());
//如果根据文件没有分析出Content-Type的值就不添加这个头了,HTTP协议规定服务端不发送这个头时由浏览器自行判断类型
if(contentType!=null){
addHeader("Content-Type",contentType);
}
addHeader("Content-Length",contentFile.length()+"");
}
public void addHeader(String name,String value){
headers.put(name,value);
}
/**
* 通过返回的字节输出流写出的字节最终会作为响应正文内容发送给客户端
* @return
*/
public OutputStream getOutputStream(){
if(baos==null){
baos = new ByteArrayOutputStream();
}
return baos;
}
public PrintWriter getWriter(){
OutputStream out = getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(out,StandardCharsets.UTF_8);
BufferedWriter bw = new BufferedWriter(osw);
PrintWriter pw = new PrintWriter(bw,true);
return pw;
}
/**
* 添加响应头Content-Type
* @param mime
*/
public void setContentType(String mime){
addHeader("Content-Type",mime);
}
}
至此,我们创建了一个服务端WebServerApplication,用于链接客户端并启动多线程;一个线程任务ClientHandler,用于执行线程里的任务;两个类,HttpServletRequest与HttpServletResponse用于接收请求与响应;一个类HandlerMapping,用于扫描当前项目目录下被注解过的类与方法;两个自定义注解,Controller与RequestMapping,用于标记类与方法;以及最后的类DispatcherServlet,用于执行被标注的方法与设置发送响应的页面;