Java实现一个Web服务器(支持重定向转发和自定义文件 类似于Tomcat)

10 篇文章 15 订阅

目录

 

简介

运行效果图

404界面(可自定义模板)

 sws文件(简单的进行重定向)

学习前需要的知识

整理思路

项目目录结构

使用的 jar(shendi-kit-1.0.jar)

编写启动类

StartWebServer

Server抽象类(所有服务器类的父类)

实现TCP服务器(DefaultServer)

请求类(DefaultHttpRequest)

响应类(DefaultHttpResponse)

 了解一下重定向

处理资源信息

图标处理

映射文件处理

热更新,加载外部类,并执行

实现转发

结尾(不要造轮子的前提是你会做这个轮子)


 

简介

I'm Shendi.

这几天写了个Web服务器练练手,主要功能如下.

  • 可以对web下资源的访问
  • 可以设置欢迎界面(index.html)
  • 可以自定义图标
  • mapper目录为私有目录
  • 提供了映射类(sws文件(简单的中文重定向),class文件(这个类似于servlet))
  • 支持重定向,转发
  • 支持热更新

Github: https://github.com/1711680493/Application 

运行效果图

index.html 界面,介绍了一些功能等.

 

404界面(可自定义模板)

500界面等与之类似

 sws文件(简单的进行重定向)


学习前需要的知识

  • 熟悉Java
  • Java的网络编程
  • TCP
  • 类加载器,
  • HTTP
  • 设计模式思想

网络编程 Socket 是必不可少的,对于Http不熟悉的可以先看一下我的爬虫教程

对于类加载器,可以先看一下我的加密教程,熟悉一下ClassLoader

 设计模式这个其实是为了更快的了解代码,可以先看下我设计模式专栏

对 Java 不熟的小白可以进入我的 Java教程专栏


整理思路

  • 首先,我们要制作的为 web 服务器,也就代表了我们需要使用到网络编程.
  • 对于 http 来说,大家都知道底层是 tcp.(我在写的时候保留了扩展,比如你可以使用 BIO 也可以随时更换为 BIO,只需要改下配置就ok了)
  • 接下来就是启动服务端进行接收,服务端只有一个(不同于Tomcat,我写的这个一个只对应一个web项目(微服务)).
  • 使用了多线程来开启接收.(并发问题,socket.accept为异步阻塞)
  • 死循环,使用冷却来减少开销,并且开启的线程数量为固定数量.
  • 为了实现重定向,转发,需要封装请求为 request,响应为 response
  • 对于普通资源来说,我们只需要通过流的形式返回就可以了.
  • 对于自定义的文件(sws),其实就是加个判断. 

项目目录结构


使用的 jar(shendi-kit-1.0.jar)

这个是我写的一个工具包,用到了处理 properties 配置文件的类 ConfigurationFactory,可以从github中下载来看使用方法.

因为暂时还没完善,所以暂不放github上,但是在这个项目里有这个jar.


编写启动类

StartWebServer

  • 启动类,包含main方法.主要作用是读取配置创建服务端(指定的class)
  • 代码如下
  • package shendi.web.server;
    
    import java.lang.reflect.InvocationTargetException;
    
    import shendi.kit.config.ConfigurationFactory;
    
    /**
     * Start web server.<br>
     * 开启网络服务器.
     * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
     * @version 1.0
     */
    public class StartWebServer {
    	/**
    	 * 这里使用反射开启服务器,目前使用的为 Socket(BIO).<br>
    	 * 为了扩展,后期可能会用到 NIO 或者新的一系列的技术.<br>
    	 * 所以这里通过读取 配置文件 + 反射 获取指定类来开启服务端.<br>
    	 * 如果需要实现自己的服务器处理请继承 {@link Server} 类.<br>
    	 * 并更改配置文件config.properties 中的 server.class 的值.<br>
    	 * config.properties 中的 server.connect.max 为现有连接的最大数量(开启了等待接收 不代表有用户)
    	 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
    	 * @param args default
    	 * @see Server
    	 * @see DefaultServer
    	 */
    	public static void main(String[] args) {
    		try {
    			//创建指定 Server 对象
    			Server server = (Server) Class.forName(ConfigurationFactory.getConfig("config").getProperty("server.class"))
    					.getDeclaredConstructor(int.class,int.class)
    					.newInstance(Integer.parseInt(ConfigurationFactory.getConfig("config").getProperty("server.port")),
    							Integer.parseInt(ConfigurationFactory.getConfig("config").getProperty("server.connect.max")));
    			//启动
    			server.start();
    		} catch (InstantiationException | IllegalAccessException | IllegalArgumentException
    				| InvocationTargetException | NoSuchMethodException | SecurityException
    				| ClassNotFoundException e) {
    			e.printStackTrace();
    		}
    	}
    }
    

     


Server抽象类(所有服务器类的父类)

 有三个属性

构造方法进行初始化(子类继承则会必须在构造内调用父类构造)

以及一个启动方法 start()

这个方法由 启动类 调用,并执行

功能是开启线程调用 server() 方法,线程数不会大于配置文件设置的数量

 抽象方法 server(),留给子类实现,在start()方法中会开启指定数量个线程调用此方法.

server()方法内应该处理请求响应.

实现TCP服务器(DefaultServer)

编写好上面的两个类后,我们已经可以获得一个一直被调用的固定数量的线程.

现在只需要编写服务器,继承自Server,实现server()方法.

我们目前要写的是http服务器,所以在 DefaultServer 中使用的自然是 TCP.

(也可以使用NIO的TCP,只需要新建一个类来实现就ok了)

 只有一个属性

构造方法中进行初始化

从父类继承的 server() 方法,也是这个类中唯一会被调用的方法.

在上面这个方法中,开启了与客户端的输入流输出流,并且将他们封装成了请求和响应

先创建的响应(因为请求中也需要用到响应)

在创建响应的时候不会执行什么操作,只是简单地将传递的 output 引用保存起来.

在创建请求的时候会执行处理数据头等操作.

先认识下 http 数据头

浏览器请求服务器,服务器会接到下面这样的格式的数据

GET / HTTP/1.1
请求类型(GET/POST...) 请求路径(例如根路径为/ get会带参数) HTTP/1.1(Http版本)
请求头...(每个请求头后都要加\r\n 换行)
换行 \r\n
请求体
如果请求头有Content-length 则使用此获取 否则判断结尾来获取
结尾为 \r\n0\r\n\r\n

注意空格和回车.

第一行的 GET / HTTP/1.1 这个代表 http 协议,并且包含了请求类型和获取的资源路径,这里的资源路径为 /(根目录)

第二行就是请求头了,请求头就是我们平常见到的请求头...例如 Content-Length: 1433

    每一个请求头后面都要带一个换行(\r\n)

第三行是\r\n(换行)

第四行就是请求体了

在了解了 http 数据结构后,我们不希望用户读取的时候从第一行读取(请求信息),并且我们也需要获取信息(请求的路径等)

所以在请求类中,我们需要将数据头给处理掉(数据体留给使用者去处理)

我们在处理的时候会遇到问题,比如说这个数据不是http协议的,我们就需要直接返回数据(进行响应)


请求类(DefaultHttpRequest)

此类属性有很多,比如处理的请求头保存的 HashMap,和用于转发存值的HashMap,请求的资源路径,请求的类型等...所以就不一一列举了.

在创建此请求对象的时候会执行处理操作

readHead()方法进行处理,代码如下

/**
 * 读取数据头.
 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
 */
protected void readHead() {
	try {
		/* 数据的第一行为请求的类型 资源路径
		 * 例如 GET / HTTP/1.1 可以看出 第一个为类型,第二个为路径,第三个为协议,空格分隔开来
		 * 如果没有此行,代表不是 HTTP 协议,直接返回 500.*/
		request = InputStreamUtils.readLine(input);
		/* 一个无效的请求,直接不管 */
		if (request == null) {
			response.setStatus(500);
			isResponse = false;
			return;
		}
		String types[] = request.split(" ");
		if (types == null || types.length < 2) {
			//返回 500
			response.write("<h1>请使用Http协议!<h1>".getBytes(encode));
			response.setStatus(500);
			isResponse = false;
			return;
		}
		/* 获取请求类型 */
		method = types[0].trim();
		/* 判断路径 这里只做对GET的支持,?后面为参数 */
		String[] paths = types[1].split("\\?");
		url = paths[0].trim();
		/* 处理参数 如果有 */
		if (paths.length > 1) {
			String[] ps = paths[1].split("\\&");
			for (var p : ps) {
				String[] content = p.split("=");
				if (content.length > 1) {
					//这里没有做空格处理
					parameters.put(content[0], content[1]);
				}
			}
		}
		/* 处理请求头 */
		String data = null;
		while ((data = InputStreamUtils.readLine(input)) != null) {
			//一个有效的数据头为键值对形式
			String[] map = data.split(":");
			if (map.length > 1) {
				headersText.append(data);
				String key = map[0].trim();
				String value = map[1].trim();
				//存入键值对
				headers.put(key, value);
			} else {
				break;
			}
		}
	} catch (IOException e) {
		e.printStackTrace();
	}
}

在知晓了请求后,我们来看看响应.


响应类(DefaultHttpResponse)

在学习响应类的时候先认识下 http 的响应数据格式(在这个类里我写了个main,可以自己运行一下看看是啥)

HTTP/1.1 200 OK
协议类型 状态 信息
请求头
换行
请求体

响应的比较简单.

第一行和请求一样,是数据头,包含了你这个请求的结果的信息

    HTTP/1.1 200 OK,中间的200代表状态码,可以为很多状态,比如404,500等,第三个OK则是代表状态信息

第二行则是请求头

第三行换行

第四行请求体

 

我们的响应是等执行操作完成后,再把所有数据返回给客户端(而不是调用一次write就输出一次)

并且在重定向,转发后,不可以输出内容.(因为不是这个页面了)

所以我们需要有一个属性来判断我们的响应是否已经结束了.

 

拥有如下属性

 拥有 write() 方法用于输出数据.以及设置状态(比如出错等),有重定向,添加请求头和完成响应,

输出数据的方法 write(); 方法将数据转为字节追加到body数组里

@Override
	public void write(String data) {
		//响应失效直接出错提示
		if (!isWrite) {
			status = 500;
			throw new IllegalStateException("你不应在已经重定向的响应中执行write!");
		}
		//添加文字数据 先将字符串转数组
		byte[] bData = data.getBytes();
		byte[] temp = body;
		body = new byte[temp.length + bData.length];
		System.arraycopy(temp, 0, body, 0, temp.length);
		System.arraycopy(bData, 0, body, temp.length, bData.length);
	}

	@Override
	public void write(byte[] data) {
		//响应失效直接出错提示
		if (!isWrite) {
			status = 500;
			throw new IllegalStateException("你不应在已经重定向的响应中执行write!");
		}
		byte[] temp = body;
		body = new byte[temp.length + data.length];
		System.arraycopy(temp, 0, body, 0, temp.length);
		System.arraycopy(data, 0, body, temp.length, data.length);
	}
	
	@Override
	public void write(byte[] data, int offset, int len) {
		//响应失效直接出错提示
		if (!isWrite) {
			status = 500;
			throw new IllegalStateException("你不应在已经重定向的响应中执行write!");
		}
		byte[] temp = body;
		body = new byte[temp.length + len - offset];
		System.arraycopy(temp, 0, body, 0, temp.length);
		System.arraycopy(data, offset, body, temp.length, len);
	}

完成响应,调用此方法代表一个请求和响应就此完成了.

方法内先试判断是否异常码 404(找不到页面) 500(服务器出错)

 请求头后面都要带换行

 了解一下重定向

重定向其实就是 状态码为 201(永久性转移) 或者 202(暂时性转移)

然后添加一个请求头 Location: 重定向跳转的路径

这样就可以完成重定向了.

201和202的区别是什么呢?

使用201的话,浏览器第一次加载后,下次,每一次(直到你清除浏览器的数据等) 都会直接跳转到你重定向的那个地址(不会在访问你的服务器)

使用202则代表先访问服务器,然后在跳转.

所以,在响应中,重定向的代码是这样的 isWrite 代表是否还可以执行write操作,重定向后不能执行write操作.

至于将数据体清空,在此方法内没真正将数据输出到客户端(浏览器) 是执行完所有的操作后才会调用 finish() 然后才执行输出.

处理资源信息

在了解了请求和响应后,再来看一下之前的DefaultServer类的server方法.

可以看出,方法内就创建了请求和响应,然后具体处理操作都在 call() 方法中.

最后,调用响应的 finish() 完成操作

所以,重要的处理部分在 call 方法.

  • 在处理资源之前,我们需要判断在请求的处理中是否出问题了,通过 isResponse 变量判断.如果出问题了我们就不处理.
  • 如果资源路径为 / 则代表为欢迎界面(index.html)
  • 我们要处理资源信息当然是先判断这个资源是否存在,存在我则直接给你返回过去.(也就是资源下载...)
    • 当然,我们还需要考虑映射文件夹(是私有的),如果访问的是映射文件夹则提示不能访问.
  • 对于映射文件(映射路径不能为文件名,一般都是直接一个英文名,不带后缀 例如 /login)
    • 所以我们需要有一个映射配置文件,通过映射来查找对应的映射sws或者映射类来进行处理

所以代码如下

/**
 * 执行调用操作,处理请求和响应.
 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
 * @param request 请求
 * @param response 响应
 */
public static void call(DefaultHttpRequest request,DefaultHttpResponse response) {
	/* 请求中如果没有进行响应操作,
	 * 则判断是否有资源,没有资源则代表为请求我们自己写的映射(Servlet),
	 * 如果两者都没有,则返回404 */
	if (request.isResponse) {
		//将系统路径存一份 (web 目录)
		StringBuilder sPath = new StringBuilder();
		sPath.append(System.getProperty("user.dir"));
		sPath.append(ConfigurationFactory.getConfig("config").getProperty("server.url"));
		//获取访问的路径
		String path = sPath.toString();
		/* 判断是否为项目根(对应 config.properties 中的 server.index 配置项) */
		if ("/".equals(request.getUrl())) {
			path += ConfigurationFactory.getConfig("config").getProperty("server.index");
		} else {
			path += request.getUrl();
		}
		String mapper = null;
		/* 先判断是否为文件 并且存在. */
		File file = new File(path);
		if (file != null && file.exists() && file.isFile()) {
			//路径不能包含 mapper 文件路径.
			if (path.contains(ConfigurationFactory.getConfig("config").getProperty("server.mapper"))) {
				response.write("不能访问系统目录.");
			} else {
				//返回此文件 
				response.write(InputStreamUtils.getFile(file));
			}
		/* 判断是否为映射 请在 mapper.properties 中配置自己的映射文件 */
		} else if ((mapper = ConfigurationFactory.getConfig("mapper").getProperty(request.getUrl())) != null) {
			//避免异常
			try {
				mapper(mapper, sPath, request, response);
			} catch (Exception | Error e) {
				response.setStatus(500);
				e.printStackTrace();
			}
		} else {
			//两者都无,为404(找不到页面)
			response.setStatus(404);	
		}
	}
}

经过以上的操作,已经可以轻松访问资源文件了

图标处理

浏览器在访问服务器的时候会偷偷地请求一次图标文件(favicon.ico).

所以我们只需要将图标放到web目录下然后命名为favicon.ico就可以了.

接下来,就是处理映射文件的访问.

映射文件处理

我们的映射文件需要在配置文件里配置.

我这写的配置文件为 mapper.properties

在配置文件内,格式为 映射路径=类全路径名

    例如 /login=shendi.web.Login

    需要注意的是.这个配置的类全路径名必须是在config.properties 中的 mapper对应的文件夹下,mapper又得在web目录下

    

    所以我的Login类位置为(是类 不是java文件)

弄好了配置文件后,我们也可以获取到对应的类/sws文件,那应该如何处理呢?

对于 sws 文件(我自己定义的)可以进行简单的重定向

    所以只需要读取文件内容,然后获取到重定向的地点,然后在代码内进行重定向就ok了.

对于 class 文件,则需要用到类加载器,类加载器可以将字节变成一个Class(然后我们得到Class进行强制转换调用就ok),待会再讲类加载器. 

 通过上面的 call() 方法,我们可以看出,在 else if 的时候判断了文件是否为映射文件(在properties中存在的配置文件).

在里面执行了 mapper() 方法,并且用try catch 盖住了,通过上面所讲的类加载器,我们强转或者把字节变成类可能会出问题,所以就需要try catch一下,然后将错误告诉给客户端.

mapper里的方法主要就是判断文件是 sws 还是 calss.

代码如下

/**
 * 执行映射文件.
 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
 * @param mapper 映射文件全路径名.
 * @param sPath web文件夹路径
 * @param request 请求
 * @param response 响应
 */
private static void mapper(String mapper,StringBuilder sPath,DefaultHttpRequest request,DefaultHttpResponse response) {
	//将 sPath 路径改到 web 下的 mapper 路径 的指定文件
	sPath.append(ConfigurationFactory.getConfig("config").getProperty("server.mapper"));
	//全路径名为 xxx.xxx.xxx 所以需要将 . 改为 /
	sPath.append(mapper.replace(".","/"));
	/* 判断文件是否为 sws 或者 class 文件,并执行对应解析 */
	File file = new File(sPath + ".sws");
	if (file.exists()) {
		//这里简单做一下处理 重定向与转发 后期在完善
		try (BufferedInputStream fInput = new BufferedInputStream(new FileInputStream(file))) {
			//只获取第一行数据判断
			var data = InputStreamUtils.readLine(fInput);
			if (data == null || !data.startsWith("重定向(\"")) {
				response.setStatus(500);
			} else {
				data = data.substring(data.indexOf('"') + 1,data.indexOf(')') - 1);
				response.redirect(data);
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
	} else if ((file = new File(sPath + ".class")).exists()) {
		/* 查找类,传递类所在位置 和 全路径名 */
		Class<?> sws = SwsClassLoader.sws.loadSwsClass(mapper,file.getPath());
		//不管出错,因为调用方已 try catch 了
		try {
			//反射调用方法
			ShendiWebServer swsObject = (ShendiWebServer) sws.getDeclaredConstructor().newInstance();
			swsObject.sws(request, response);
		} catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
			response.setStatus(500);
			e.printStackTrace();
		}
	} else {
		//映射文件不合格 直接500
		response.setStatus(500);
	}
}

上面代码的class部分执行的类加载器加载的class,然后强制转换为 ShendiWebServer 接口调用操作处理.(传递了请求和响应)

在ShendiWebServer接口里定义的是行为

映射类需要实现此接口,所以我讲这几个类打成jar供使用

热更新,加载外部类,并执行

让一个类继承自ClassLoader,使用defineClass方法将字节变为类,实现加载.

注意: ClassLoader对象不能重新加载已经加载过的Class(也就是不能热更新),但是不同对象的ClassLoader可以.(new 自己)

/**
 * 用于处理映射类,继承自 ShendiWebServer 的类
 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
 * @version 1.0
 */
public final class SwsClassLoader extends ClassLoader {
	/**
	 * 此类只提供一个功能,就是加载映射类并调用.
	 */
	private SwsClassLoader() {}
	
	public static SwsClassLoader sws = new SwsClassLoader();
	
	/**
	 * 享元模式,类池.
	 */
	private HashMap<String,Class<?>> swsClass = new HashMap<>();
	
	/**
	 * 查找指定类并加载成class.
	 * @param name 类的全路径名
	 * @param path 文件路径
	 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
	 */
	public Class<?> loadSwsClass(String name,String path) {
		//判断是否热更新
		if ("true".equals(ConfigurationFactory.getConfig("config").getProperty("update"))) {
			//ClassLoader要重新加载class则需要一个新的ClassLoader对象...
			sws = new SwsClassLoader();
			//获取指定class文件字节流形式
			byte[] classData = InputStreamUtils.getFile(path);
			//将字节转class.
			//这里第一个参数为 name,设置为null代表是未知的(因为可能包名不同等导致加载失败)
			return defineClass(null,classData,0,classData.length);
		} else {
			//判断池子里有无指定class
			if (swsClass.containsKey(name)) {
				return swsClass.get(name);
			} else {
				//获取指定class文件字节流形式
				byte[] classData = InputStreamUtils.getFile(path);
				//将字节转class.
				//这里第一个参数为 name,设置为null代表是未知的(因为可能包名不同等导致加载失败)
				Class<?> clazz = defineClass(null,classData,0,classData.length);
				//存入池子
				swsClass.put(name, clazz);
				return clazz;
			}
		}
	}
	
}

实现转发

拥有以上的功能后,已经算是一个服务器了.最后,实现一个转发功能.在请求的地方实现

实现转发的思路(请求响应不变,换了一个类去执行)

也就是调用转发这一行的代码就会去执行转发的class的操作.(也就是改变url再次调用一次call()方法(这也是我为什么把方法抽出来的原因))

 

/**
	 * 转发.
	 * @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
	 * @param path 转发的路径
	 * @param req 请求
	 * @param resp 响应
	 */
	public void forward(String path,DefaultHttpRequest req,DefaultHttpResponse resp) {
		//将路径替换
		url = path;
		//在此调用处理请求和响应的方法
		DefaultServer.call(req, resp);
	}

结尾(不要造轮子的前提是你会做这个轮子)

完成以上功能,一个Web服务器就差不多做好了.

可以运行试一下.源码在github上.github在顶部

如果对你有帮助,点个赞再走吧~

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

HackShendi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值