前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用java自制简易web服务器

使用java自制简易web服务器

原创
作者头像
haohulala
发布2023-03-11 14:42:03
1.3K0
发布2023-03-11 14:42:03
举报
文章被收录于专栏:web应用编程web应用编程

什么是web服务器

记得好多年前,刚刚开始学javaweb的时候,老师教的第一件事是安装jdk,第二件事就是安装tomcat了。

当时老师的操作是,下载完压缩包后解压,然后把tomcat的bin目录添加到环境变量里面,然后打开黑乎乎的cmd,输入catalina就可以运行tomcat了。当时还不知道为什么只要添加了环境变量,就可以在cmd里面启动tomcat,更不要说为什么我们什么都没有设置,输入一个命令就能启动web服务器了。

这个问题其实困惑了我好久好久,不过当时由于水平有限,以及网上的课程大多都是教你怎么搭建web服务器,怎么编写servlet,很少有人会去探究web服务器究竟是怎么运行起来的,在输入命令背后又进行了那些操作,以及如何加载我们的servlet进行服务等等。

这些东西对于找工作来说也许一点用也没有,但是我真的好奇,于是去网络上寻找答案,最终理顺了一个简易版的web服务器的运行流程。

简单来说(我们就说最最简单的情况),web服务器就是一个可以用socket接收客户端连接,然后进行HTTP协议解析和相应软件。没错,就是一个软件而已,当然,像tomcat这样非常流行,并且可以用于生产环境的web服务器的内部逻辑是非常非常复杂的,因为要应对生产环境中的各种问题。

从上图中可以看出来,一个完整的web服务一般可以分为客户端和服务端,客户端就是各种可以连接网络的终端,比如浏览器,安卓手机,苹果手机等等;服务端值得就是我们编写的业务代码,这个根据业务的不同,编写的代码也不同。web服务器实际上可以看成是我们javaweb应用的容器,我们编好了代码就放到web服务器里运行,可以简单理解成web服务器+业务代码=完整的web服务

web服务器起到了连接客户端和服务端的目的,不管公司的业务是什么,这部分的需求基本都是一致的,要求高并发,高可用等等。有了tomcat这样的开源web框架,大家就可以不用自己去编写web服务器的代码了,而是专注于自己的具体业务,这就是软件开源的意义。

HTTP协议

上图中,我们认为客户端和服务端是使用HTTP协议进行通信的,事实上也是如此,不过这不是固定的,你也可以定义一个通信协议,只要有人愿意使用你定义的通信协议进行通信就行。

不过,现如今互联网用的最多的通信协议还是HTTP。我们知道HTTP是应用层的协议,所谓应用层的协议,我的理解就是,操作系统底层不提供,需要你自己编写代码解析的协议。类似TCP/UDP这种通信协议操作系统都帮你写好了,你只需要进行系统调用就行了。当然,如果你用的是java这种更加高级的编程语言,那么你需要调用的api就更少了,因为很多细节已经帮你封装好了。

我们要写web服务器的话,就要能相应客户端发过来的HTTP请求信息。下面,我们用一个简单程序来看一下HTTP相应头都有哪些信息。

代码语言:javascript
复制
public class HttpRequest {

    public static void main(String[] args) {
        try {
            URL url = new URL("https://backdata.net/");
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            Map<String, List<String>> map = conn.getHeaderFields();
            List<String> keylist = new ArrayList<>(map.keySet());
            for(int i=0; i<keylist.size(); i++) {
                System.out.println(keylist.get(i) + ": " + map.get(keylist.get(i)));
            }
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

}

运行后可以得到下面的结果

上图中后面三个框的信息是必须的,我们在写建议的web服务器的时候,只需要相应三个响应头信息就行了。

什么是Servlet

不知道你们是不是和我一样,刚刚开始学javaweb的时候就听老师说写servlet,然后注册,然后就可以映射到url请求了,但是整个流程是怎么运转起来的却一头雾水。

其实servlet就是一个javaweb定义的标准而已,servlet是一个接口,里面定义了几个方法,所有的servlet都需要实现接口里面的那些方法。

我们现在也定义一个简单的servlet。

首先定义一个接口,命名为BaseServlet,这个接口是所有Servlet的基础

代码语言:javascript
复制
public interface BaseServlet {

    public void doService(ServletRequest servletRequest, ServletResponse servletResponse);

}

可以看到,接口只有一个doService()方法,其中有两个参数,分别是ServletRequest 和ServletResponse ,这两个类就是我们在处理请求和相应时用到的类,他们的定义分别如下所示

代码语言:javascript
复制
public class ServletRequest {

    private String path;

    public ServletRequest() {
    }

    public ServletRequest(String path) {
        this.path = path;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }
}
代码语言:javascript
复制
public class ServletResponse {

    private int code;
    private String msg;
    private String content_type;

    public ServletResponse() {
    }

    public ServletResponse(int code, String msg, String content_type) {
        this.code = code;
        this.msg = msg;
        this.content_type = content_type;
    }


    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public String getContent_type() {
        return content_type;
    }

    public void setContent_type(String content_type) {
        this.content_type = content_type;
    }
}

当然,我们这里只是进行简单定义,随着需求的复杂化,这两个类肯定也是要变得更加复杂的。总的来说,这两个类就是在进行http请求处理的时候传递信息用的。

有了上述的基础,我们就可以定义自己的具体业务类,基础BaseServlet,重写doService()方法,怎么样,是不是有javaweb那个味道了,逻辑是以上的,不过tomcat肯定进行了更加复杂的处理。

我们就举一个简单的例子

代码语言:javascript
复制
public class HelloServlet implements BaseServlet{
    @Override
    public void doService(ServletRequest servletRequest, ServletResponse servletResponse) {
        String msg = "<html><head><title>HelloServlet</title></head>" +
                "<body><div><h1>hello servlet</h1></div></body>" +
                "</html>";
        servletResponse.setCode(200);
        servletResponse.setContent_type("application/xml");
        servletResponse.setMsg(msg);
    }
}

上面的代码相信不难理解,实际上就是构造HTTP响应体,实际业务中可能会与数据库交互等等复杂逻辑。

Simplecat的生命周期

由于是简化版的web服务器,所以我暂时命名为simplecat。我们定义一个非常非常简单的生命周期,那就是

初始化->开启服务,开启客户端请求->关闭服务

我们将生命周期写在启动代码里

代码语言:javascript
复制
public class SimplecatBootstrap {

    public static void main(String[] args) {
        Server server = new Server();
        // 1. 初始化环境:加载boot.properties,然后将servlet实例化
        server.init();
        // 2. 开启服务端监听,接收客户端请求
        server.start();
        // 3. 关闭服务
        server.shutdown();
    }

}

启动代码实例化了Server类,这个类里面编写了服务端的三个生命周期

代码语言:javascript
复制
public class Server {

    private final int PORT = 8082;

    // 用来存放socket连接
    private ThreadPoolExecutor threadpool;
    // 用来存放servlet
    public static Map<String, BaseServlet> servletMap;

    public Server() {

    }

    public void init() {
        System.out.println("初始化环境...");
        threadpool = new ThreadPoolExecutor(16, 128,
                1000, TimeUnit.MICROSECONDS,
                new ArrayBlockingQueue<Runnable>(16));
        servletMap = new HashMap<>();
        // 读取配置文件,将servlet注册进去
        Properties properties = new Properties();
        File file = new File("res/boot.properties");
        try {
            properties.load(new FileInputStream(file));
            List<String> strList = new ArrayList<>(properties.stringPropertyNames());
            for(int i=0; i<strList.size(); i++) {
                Class<?> clazz = Class.forName(strList.get(i));
                BaseServlet servlet = (BaseServlet) clazz.newInstance();
                servletMap.put((String)properties.get(strList.get(i)), servlet);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }

    }

    public void start() {
        System.out.println("服务端启动...");
        try {
            ServerSocket serverSocket = new ServerSocket(PORT);
            while(true) {
                Socket socket = serverSocket.accept();
                System.out.println("接收一个socket连接请求:" + socket.getInetAddress().toString());
                ClientThread thread = new ClientThread(socket);
                threadpool.execute(thread);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }


    public void shutdown() {
        System.out.println("服务端停止...");
        threadpool.shutdown();
        servletMap.clear();
    }

}

其中我们定义了一个Map<String, BaseServlet>来存放我们的servlet。下面我们主要介绍一下初始化环境的操作

代码语言:javascript
复制
    public void init() {
        System.out.println("初始化环境...");
        threadpool = new ThreadPoolExecutor(16, 128,
                1000, TimeUnit.MICROSECONDS,
                new ArrayBlockingQueue<Runnable>(16));
        servletMap = new HashMap<>();
        // 读取配置文件,将servlet注册进去
        Properties properties = new Properties();
        File file = new File("res/boot.properties");
        try {
            properties.load(new FileInputStream(file));
            List<String> strList = new ArrayList<>(properties.stringPropertyNames());
            for(int i=0; i<strList.size(); i++) {
                Class<?> clazz = Class.forName(strList.get(i));
                BaseServlet servlet = (BaseServlet) clazz.newInstance();
                servletMap.put((String)properties.get(strList.get(i)), servlet);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }

    }

这个操作的逻辑是

1. 加载 boot.properties 文件 2. 根据加载的文件内容实例化servlet,然后存放到map中

boot.properties文件中记录了我们需要被实例化的servlet

比如上图中,当客户端访问book这个路径的时候,就是交给BookServlet这个servlet进行处理的。

HTTP响应处理逻辑

我们将每一个客户端连接独立出来写成ClientThread,这个类继承Thread类,并且重写run方法,这样就可以丢到线程池里面进行处理了。

代码语言:javascript
复制
public class ClientThread extends Thread{

    private Socket socket;

    public ClientThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        super.run();
        try {
            // 建立连接,监听客户端请求
            InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream();

            // 1. 接收详细的请求头
            String requestHead = SocketUtil.parseRequestHead(inputStream);
            // 2. 拿到请求路径
            String path = SocketUtil.getPath(requestHead);
            // 3. 根据请求路径进行分发
            ServletResponse servletResponse = ServletHandler.dispatch(path);
            // 4. 根据servletResponse的信息构造相应信息
            String response = SocketUtil.getResponse(servletResponse);
            // 5. 发送请求
            SocketUtil.sendResponse(outputStream, response);

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

可以看到具体的处理流程一共有五步

1. 接收详细的请求头 2. 从请求头中拿到路径 3. 根据请求路径进行请求的分发 4. 根据servletResponse的信息构建响应信息 5. 发送HTTP响应给客户端

以上五个步骤逻辑比较复杂的是请求路径的分发,我们的具体分发逻辑写在了ServletHandler这个类中

代码语言:javascript
复制
public class ServletHandler {
    private static final String PATH_INDEX = "res/html/index.html";
    private static final String PATH_404 = "res/html/404.html";

    // 根据请求路径进行详细的分发
    public static ServletResponse dispatch(String path) {
        // 构建ServletRequest和ServletResponse对象
        ServletRequest servletRequest = new ServletRequest(path);
        ServletResponse servletResponse = new ServletResponse();
        System.out.println(path);
        // 如果是根目录,那么直接返回index.html即可
        if(path.equals("/")) {
            servletResponse.setCode(200);
            servletResponse.setMsg(SocketUtil.getFile(new File(PATH_INDEX)));
            servletResponse.setContent_type("text/html");
        }
        // 看一下是否存在servlet可以响应请求
        else if(Server.servletMap.containsKey(path)) {
            Server.servletMap.get(path).doService(servletRequest, servletResponse);
        }
        // 返回静态文件
        else {
            File file = new File("res/html/" + path);
            // 文件存在,则code200,msg就是文件内容
            if(file.exists()) {
                servletResponse.setCode(200);
                servletResponse.setMsg(SocketUtil.getFile(file));
            }
            // 若文件不存在,则code404,msg就是404.html文件的内容
            else {
                servletResponse.setCode(404);
                servletResponse.setMsg(SocketUtil.getFile(new File(PATH_404)));
            }
            servletResponse.setContent_type("text/html");
        }
        return servletResponse;
    }

}

分发的逻辑如下

1. 如果是根目录,那么就返回index.html 2. 如果有对应的servlet,那么就调用具体的servlet的doService()方法进行处理 3. 否则寻找相应的静态资源,如果找到了就返回相应的页面,否则返回404.html页面

最后,我们将一些工具类都封装到了SocketUtil这个类中了,就是一些简单的处理逻辑

代码语言:javascript
复制
public class SocketUtil {

    // 一次发送的字节数
    private static final int CHUNK = 100;

    public static String parseRequestHead(InputStream inputStream) {
        StringBuilder sb = new StringBuilder();
        byte[] bytebuf = new byte[1024];
        int len = 0;
        while(true) {
            try {
                len = inputStream.read(bytebuf);
                if(len == -1){
                    continue;
                }
                sb.append(new String(bytebuf, 0, len));
                // 如果已经接收完毕请求头就结束
                if(check(sb.toString())) {
                    return sb.toString();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    // 检验是否已经将请求头接收完毕
    // 这里的判断逻辑就是最后四个字符是否是\r\n\r\n
    private static boolean check(String str) {
        if(str.length()>=4 && str.substring(str.length()-4).equals("\r\n\r\n")) {
            return true;
        }
        return false;
    }

    // 获取请求路径
    // 我们这里进行简化处理,只取最后一个路径作为请求路径
    // 有两种请求资源,第一种是servlet请求,比如http://localhost:8082/hello
    // 第二种是对html资源的请求,比如http://localhost:8082/hello.html
    public static String getPath(String requestHead) {
        String line = requestHead.split("\n")[0];
        String path = line.split(" ")[1];
        // 如果是根目录直接返回即可
        if(path.equals("/")){
            return path;
        }
        path = path.split("/")[path.split("/").length-1];
        return path;
    }

    // 将文件读成String对象
    public static String getFile(File file) {
        StringBuilder sb = new StringBuilder();
        try {
            FileInputStream fis = new FileInputStream(file);
            byte[] bytebuf = new byte[1024];
            int c;
            while((c=fis.read(bytebuf))!=-1) {
                sb.append(new String(bytebuf, 0, c));
            }
            return sb.toString();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return sb.toString();
    }

    // 根据servletResponse构造响应体
    public static String getResponse(ServletResponse servletResponse) {
        StringBuilder sb = new StringBuilder();
        sb.append("HTTP/1.1 " + servletResponse.getCode() + " OK\r\n");
        sb.append("Content-Length: " + servletResponse.getMsg().getBytes().length + "\r\n");
        sb.append("Content-Type: " + servletResponse.getContent_type() +"\r\n\r\n");
        sb.append(servletResponse.getMsg());
        return sb.toString();
    }

    // 发送请求
    public static void sendResponse(OutputStream outputStream, String response) {
        System.out.println(response);
        try {
            for (int i = 0; i < response.length() / CHUNK; i++) {
                // 超过CHUNK字节数的部分
                if (i != 0 && i != response.length() / CHUNK - 1) {
                    outputStream.write(response.substring(i * CHUNK, i * CHUNK + CHUNK).getBytes());
                    outputStream.flush();
                }
                // 不满CHUNK字节数,全部发送
                else if(i==0 && response.length()/CHUNK==0) {
                    outputStream.write(response.getBytes());
                    outputStream.flush();
                }
                // 发送剩余部分
                else {
                    outputStream.write(response.substring(i*CHUNK).getBytes());
                    outputStream.flush();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

运行效果

在看运行效果前,先来看一下项目的目录结构

从目录结构中可以看出来,我们将静态资源和配置文件都放到和src文件同级的res文件夹中。

接着我们就来看一下用浏览器进行各种请求的结果

可以发现基本实现了目标哈哈,既可以响应静态资源,可以调用servlet进行逻辑处理,当找不到响应servlet和静态资源的时候,响应一个404页面。

结语

我们只是做了一个非常非常简单的web服务器,可以响应浏览器的简单请求,这篇文章的目的只是介绍一下web服务器的基本原理。

我把代码都放到gitee上了,如果有需要的话自行下载就行。

https://gitee.com/haohulala/simplecat

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是web服务器
  • HTTP协议
  • 什么是Servlet
  • Simplecat的生命周期
  • HTTP响应处理逻辑
  • 运行效果
  • 结语
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档