Version: Next

自制 Web MVC 框架

一个 Web MVC 框架要实现的功能

  • 接收客户端发来的 HTTP Request 请求
  • 在传输层,使用 TCP/IP 确定 IP 地址、端口号等,在高级语言层面对应 Socket 编程
  • 基于 HTTP 协议解析 (请求头、请求体、Path、Query 等)这个请求,把 请求分发 到对应的函数、方法,同时传递请求携带的 参数
  • 将处理完毕的内容,以 byte[] 格式作为响应体,配合响应头等,返回给客户端
    • 响应体内容应当是一个 HTML 文件
    • HTML 文件大部分内容是静态不变的,少部分是动态的,即后台的数据,根据业务逻辑进行填充
    • 此处使用 FreeMarker 模板引擎

Request 类

定义一个类,对应一个 HTTP 请求实体,包含

  • HTTP 请求方法 method (GET、POST 等)
  • 请求头 headers
  • 路径 path,后面可以拼一个 ?? 之后的部分是 query 参数
  • 请求体 body 也可以携带参数
  • http://jsonplaceholder.com/comments?postId=1
    • http:为协议
    • jsonplaceholder.com:为主机 HOST
    • comments:为路径 path
    • postId=1:为参数 query
  • 请求过来就是一个大字符串,通过字符串裁剪,逐步解析出这些内容,并填充到 Request 类中的成员变量里,方便后续业务逻辑直接使用
Request 类
public class Request {
// header, body
// header 里面又分请求行, 和其他部分
// 请求行里面又分 method(Get, Post), path
// path 里面可以带参数
// Body 里面也可以带参数
public String rawData;
public String method;
public String path;
public String body;
public HashMap<String, String> query; // path 后面?拼的参数
public HashMap<String, String> form; // body 里的数据
public HashMap<String, String> headers; // 请求头
public HashMap<String, String> cookies;
public Request(String rawRequest) {
this.rawData = rawRequest;
String[] parts = rawRequest.split("\r\n\r\n", 2);
this.body = parts[1]; // 切出请求体
String headers = parts[0]; // 切出请求头
this.addHeaders(headers); // 调用方法,解析请求头并存入map
String[] lines = headers.split("\r\n");
String requestLine = lines[0];
String[] requestLineData = requestLine.split(" ");
this.method = requestLineData[0]; // 切出请求方法 GET、POST
this.parsePath(requestLineData[1]); // 切出请求路径 /add,/query 等,可能在后面拼着 ?id=1 这种东西
this.parseForm(body); // 解析请求体
log("path query: %s", this.query);
}
private void addHeaders(String headerString) {
this.headers = new HashMap<>();
String[] lines = headerString.split("\r\n");
for (int i = 1; i < lines.length; i++) {
String line = lines[i];
String[] kv = line.split(":", 2);
this.headers.put(kv[0].strip(), kv[1].strip());
}
if(this.headers.containsKey("Cookie")) {
this.cookies = new HashMap<>();
String cookieString = this.headers.get("Cookie");
String args[] = cookieString.split(";");
for (String kvString: args) {
String[] kv = kvString.split("=", 2);
if (kv.length >= 2) {
this.cookies.put(kv[0].strip(), kv[1].strip());
} else {
this.cookies.put(kv[0].strip(), kv[0].strip());
}
}
} else {
this.cookies = new HashMap<>();
}
}
private static void log(String format, Object... args) {
System.out.println(String.format(format, args));
}
private void parsePath(String path) {
// path = URLDecoder.decode(path, StandardCharsets.UTF_8);
Integer index = path.indexOf("?");
if (index.equals(-1)) { // 没有携带 query参数
this.path = path;
this.query = null;
} else { // 携带了 query参数 把query捣鼓出来
this.path = path.substring(0, index);
String queryString = path.substring(index + 1);
log("queryString: %s", queryString);
// query 参数之间用 & 分割的
String[] args = queryString.split("&");
this.query = new HashMap<>();
for (String e: args) {
// author=
log("e: %s", e);
// 每一个query参数又是 key=value 的形式
String[] kv = e.split("=", 2);
String k = URLDecoder.decode(kv[0], StandardCharsets.UTF_8);
String v = URLDecoder.decode(kv[1], StandardCharsets.UTF_8);
log("k: <%s>, v: <%s>", k, v);
this.query.put(k, v);
}
// 等价于下面
// for (int i = 0; i < args.length; i++) {
// String e = args[i];
// }
}
}
private void parseForm(String body) {
// ""
// " "
body = body.strip();
String contentType = this.headers.get("Content-Type");
if (contentType == null) {
this.form = new HashMap<>();
} else if (contentType.equals("application/x-www-form-urlencoded")) {
if (body.length() > 0) {
// author=ddd&message=ddd
String formString = body;
log("queryString: %s", formString);
String[] args = formString.split("&");
this.form = new HashMap<>();
for (String e: args) {
// author=
log("e: %s", e);
String[] kv = e.split("=", 2);
String k = URLDecoder.decode(kv[0], StandardCharsets.UTF_8);
String v = URLDecoder.decode(kv[1], StandardCharsets.UTF_8);
log("k: <%s>, v: <%s>", k, v);
this.form.put(k, v);
}
}
} else if (contentType.equals("application/json")) {
this.form = new HashMap<>();
} else {
this.form = new HashMap<>();
}
}
}

SocketOperator 类

接收一个 socket 对象,封装它,使得基于 socket 的接收、发送操作变简单

public class SocketOperator {
private static void log(String format, Object... args) {
System.out.println(String.format(format, args));
}
public static String socketReadAll(Socket socket) throws IOException {
InputStream input = socket.getInputStream();
InputStreamReader reader = new InputStreamReader(input);
int bufferSize = 100;
// 初始化指定长度的数组
char[] data = new char[bufferSize];
// 通常我们会用 StringBuilder 来处理字符串拼接
// 它比 String 相加运行效率快很多
StringBuilder sb = new StringBuilder();
while (true) {
// 读取数据到 data 数组,从 0 读到 data.length
// size 是读取到的字节数
int size = reader.read(data, 0, data.length);
// 判断是否读到数据
if (size > 0) {
sb.append(data, 0, size);
}
// 把字符数组的数据追加到 sb 中
log("size and data: " + size + " || " + data.length);
// 读到的 size 比 bufferSize 小,说明已经读完了
if (size < bufferSize) {
break;
}
}
return sb.toString();
}
public static void socketSendAll(Socket socket, byte[] r) throws IOException {
OutputStream output = socket.getOutputStream();
output.write(r);
}
}

Server 类

服务器实体

  • 这里写的就是最宏观的 MVC 服务器的逻辑
  • 开一个 socket 等待连接
  • 解析 HTTP 请求,构建一个 Request 类的对象出来
  • 根据 Path 把这个 Request 传递到业务层对应的方法进行处理
  • 把处理好的后端数据,通过 FreeMarker 模板引擎嵌入到一个 HTML 文件中
  • 把这个 HTML 以 byte[] 的形式,通过 socket 返回客户端
public class Server {
static ArrayList<Message> messageList = MessageService.load();
public static void run(Integer port) {
// 监听请求
// 获取请求数据
// 发送响应数据
// 我们的服务器使用 9000 端口
// 不使用 80 的原因是 1024 以下的端口都要管理员权限才能使用
// int port = 9000;
Utils.log("服务器启动, 访问 http://localhost:%s", port);
try (ServerSocket serverSocket = new ServerSocket(port)) {
while (true) {
// accept 方法会一直停留在这里等待连接
try (Socket socket = serverSocket.accept()) {
// 客户端连接上来了
Utils.log("client 连接成功");
// 读取客户端请求数据
String request = SocketOperator.socketReadAll(socket);
byte[] response;
if (request.length() > 0) {
// 输出响应的数据
Utils.log("请求:\n%s", request);
// 解析 request 得到 path
Request r = new Request(request);
// 根据 path 来判断要返回什么数据
// byte[] 数组接收
response = responseForPath(r);
} else {
response = new byte[1];
Utils.log("接受到了一个空请求");
}
SocketOperator.socketSendAll(socket, response);
}
}
} catch (IOException ex) {
System.out.println("exception: " + ex.getMessage());
}
}
// 分发 Request 到对应的业务逻辑模块
// 相当于 SpringMVC 的 @RequestMapping
private static byte[] responseForPath(Request reqeust) {
HashMap<String, Function<Request, byte[]>> map = Route.routeMapAll(); // 通过 Route 类的 routeMapAll() 方法获取所有 path 到业务方法的映射,后面会说
Function<Request, byte[]> function = map.getOrDefault(reqeust.path, Route::route404); // 能找到存在的映射,就获取对应的方法,并执行;找不到,就去执行 route404这个方法
byte[] response = function.apply(reqeust);
return response;
}
}

Route 类 (Controller)

相当于 SpringMVC 中的 Controller

  • 要实现的功能是:让 path 触发对应的业务层方法
  • SpringMVC 中:比如要添加一个用户,RESTFul 的请求 path 是 /add,那么我们会在 Controller 的某个方法上加 @RequestMapping("/add") 注解,之后 /add 就会触发这个被注解的方法

直观实现方法

  • if..else 判断 path,会出现如下代码
if..else 伪代码
if (path.equals("/add")) {
add(); // 执行 add 方法
} else if (path.equals("/delete")) {
delete(); // 执行 delete 方法
} // ....

显然这种写法非常烂,烂在:

  1. 每加一个 Path 这段 if...else 都要改,而回想一下 SpringMVC 的这一部分逻辑用户根本不用管,直接注解就完事了
  2. 必须一个一个判断过去,性能不好,应该弄一种一次定位到对应方法的写法
  3. 可读性差

利用 HashMap 和函数式接口

  • HashMap 通过 HashCode 查询,一步到位,时间复杂度 O(1)
  • 函数式接口实现 Map 里存方法的引用,value 是一个函数式接口,接受一个 Request 返回一个 byte[],Key 就是 Path,
  • Function<Request, byte[]> 是一个函数式接口,意思传入一个 Request 返回一个 byte[]
HashMap<String, Function<Request, byte[]>> map = new HashMap<>();
Route 类中的路由分发逻辑
public class Route {
public static HashMap<String, Function<Request, byte[]>> routeMapAll() {
HashMap<String, Function<Request, byte[]>> map = new HashMap<>();
// map.put("/doge.gif", Route::routeImage);
// map.put("/doge1.jpg", Route::routeImage);
// map.put("/doge2.gif", Route::routeImage);
map.put("/static", Route::routeImage);
// 把其他子模块中的 映射Map 也添加过来,集合在一起
map.putAll(RouteTodo.routeMap());
map.putAll(RouteUser.routeMap());
map.putAll(RoutePublic.routeMap());
map.putAll(RouteAjaxTodo.routeMap());
return map;
}
// 带响应头的响应方法
public static String responseWithHeader(int code, HashMap<String, String> headerMap, String body) {
String header = String.format("HTTP/1.1 %s\r\n", code);
for (String key: headerMap.keySet()) {
String value = headerMap.get(key);
String item = String.format("%s: %s \r\n", key, value);
header = header + item;
}
String response = String.format("%s\r\n%s", header, body);
return response;
}
}

子模块的 Route

以用户模块为例

用户模块的 Route
public class RouteUser {
public static HashMap<String, Function<Request, byte[]>> routeMap() {
HashMap<String, Function<Request, byte[]>> map = new HashMap<>();
// path,加方法引用,指向本类中的方法
map.put("/login", RouteUser::login);
map.put("/register", RouteUser::register);
return map;
}
public static byte[] login(Request request) {
HashMap<String, String> header = new HashMap<>();
header.put("Content-Type", "text/html");
HashMap<String, String> data = null;
if (request.method.equals("POST")) {
data = request.form;
}
Utils.log("login: <%s>", data);
String loginResult = "";
Utils.log("login before data");
if (data != null) {
Utils.log("login data not null");
String username = data.get("username");
String password = data.get("password");
if (UserService.validLogin(username, password)) {
String sessionId = UUID.randomUUID().toString();
User user = UserService.findByUsername(username);
SessionService.add(sessionId, user.id);
header.put("Set-Cookie", String.format("session_id=%s", sessionId));
loginResult = "登录成功";
} else {
loginResult = "登录失败";
}
}
HashMap<String, Object> d = new HashMap<>();
// 后端处理生成的数据存到 HashMap 里
d.put("result", loginResult);
// 把数据 通过 FreeMarker 模板类渲染仅 ftl 生成最终的 HTML 文件
String body = FreeMarkerTemplate.render(d, "login.ftl");
// 调用 Route 的 responseWithHeader,填入状态码,响应头、请求体就是渲染好的 HTML 文件
String response = Route.responseWithHeader(200, header, body);
return response.getBytes(StandardCharsets.UTF_8);
}
// ....
}
  • 用 Map 存储这种映射关系,其中 value 是一个方法引用 RouteUser::login
  • 这其实是 Lambda 表达式的更精简写法,这里本来是 lambda 表达式,传入的参数是 Request,表达式中将 Requset 再传递给 RoutUser.login(request) 方法,而这个方法的接收的参数正好就是一个 Request。即,当 lambda 表达式传入的参数和表达式中调用的那个方法接收的参数一模一样的时候,就可以写成方法引用的形式(具体可以看 Lambda 表达式和函数式接口的笔记)
  • 业务层调用持久层访问数据库,处理,并生成数据,通过 FreeMarker 模板引擎把数据渲染到 ftl 文件里,最后捣鼓成 HTML
  • 调用 Route 类的 responseWithHeader 方法,按照 HTTP 的约定,填入状态码,响应头,渲染好的 HTML 页面,并以 byte[] 的形式返回出去,再外层 socket 会把它发回客户端

页面未找到 404 的路由

在 Route 的 Map 里找不到的 Path,就应该触发 404,那么我们给这种请求一个统一的方法引用,指向一个 404 方法,返回一个 404 页面

  • 直接拿字符串拼一个 HTTP 报文
public static byte[] route404(Request request) {
String body = "<html><body><h1>404</h1><br><img src='/doge2.gif'></body></html>";
String response = "HTTP/1.1 404 NOT OK\r\nContent-Type: text/html;\r\n\r\n" + body;
return response.getBytes(StandardCharsets.UTF_8);
}

FreeMarkerTemplate 类

用来把后端数据切入到 FTL 文件中的

  • 其实就是根据 FreeMarker 的语法,把有语法的位置替换成真实的后端数据
  • 主要是告诉 FreeMarker,Ftl 文件去哪个路径找
  • render 方法实现把 FreeMarker 语法替换成真实的后端数据
public class FreeMarkerTemplate {
static Configuration config;
//
static {
// static 里面的东西只会被初始化一次
config = new Configuration(
Configuration.VERSION_2_3_28);
String resource = String.format("%s.class", Utils.class.getSimpleName());
Utils.log("resource %s", resource);
Utils.log("resource path %s", Utils.class.getResource(""));
var res = Utils.class.getResource(resource);
if (res != null && res.toString().startsWith("jar:")) {
config.setClassForTemplateLoading(Utils.class, "/templates");
} else {
try {
File f = new File("build/resources/main/templates");
config.setDirectoryForTemplateLoading(f);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
config.setDefaultEncoding("utf-8");
config.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
config.setLogTemplateExceptions(false);
config.setWrapUncheckedExceptions(true);
}
public static String render(Object data, String templateFileName) {
Template template;
try {
template = config.getTemplate(templateFileName);
} catch (IOException e) {
throw new RuntimeException(e);
}
ByteArrayOutputStream result = new ByteArrayOutputStream();
Writer writer = new OutputStreamWriter(result);
try {
template.process(data, writer);
} catch (TemplateException | IOException e) {
String messsage = String.format("模板 process 失败 <%s> error<%s>", data, e);
throw new RuntimeException(messsage, e);
}
return result.toString();
}
}

Response 响应

到这里,所有的核心代码已经完了,但是流程还没完,所以接下来看的都是旧代码,但是继续讲解流程

  • 后端数据已经填充好并生成了 HTML,接下来就是把它响应给客户端

再看 Route.responseWithHeader

  • 每个具体的业务方法,最后对吼调用这个方法进行相应
  • 类似于 SpringMVC 最后 Controller 的返回值
    • 对于单体式应用一般走视图解析器 ModelAndView;对于前后端分离应用一般是返回一个 JSON,直接写字符串或者 HashMap 之类的东西,@ResponseBody 或者 @RestController 会自动处理成 JSON
  • 自制的 MVC 是统一调这个方法捣鼓一个 HTTP 报文,外层会通过 socket 把它法回客户端
public static String responseWithHeader(int code, HashMap<String, String> headerMap, String body) {
String header = String.format("HTTP/1.1 %s\r\n", code);
for (String key: headerMap.keySet()) {
String value = headerMap.get(key);
String item = String.format("%s: %s \r\n", key, value);
header = header + item;
}
String response = String.format("%s\r\n%s", header, body);
return response;
}

再看 Server.run(Integer port) 方法

  • 这个方法中的 byte[] response 变量,最后接收道德就是上面 Route.responseWithHeader 方法的返回值,因为 response = responseForPath(r); 就是分发路由、处理、并获取后台响应
  • 可以看到接下来就会通过 SocketOperator.socketSendAll(socket, response); 使用 socket 把渲染好的 HTML 发出去
Server 类
public class Server {
static ArrayList<Message> messageList = MessageService.load();
public static void run(Integer port) {
// 监听请求
// 获取请求数据
// 发送响应数据
// 我们的服务器使用 9000 端口
// 不使用 80 的原因是 1024 以下的端口都要管理员权限才能使用
// int port = 9000;
Utils.log("服务器启动, 访问 http://localhost:%s", port);
try (ServerSocket serverSocket = new ServerSocket(port)) {
while (true) {
// accept 方法会一直停留在这里等待连接
try (Socket socket = serverSocket.accept()) {
// 客户端连接上来了
Utils.log("client 连接成功");
// 读取客户端请求数据
String request = SocketOperator.socketReadAll(socket);
byte[] response;
if (request.length() > 0) {
// 输出响应的数据
Utils.log("请求:\n%s", request);
// 解析 request 得到 path
Request r = new Request(request);
// 根据 path 来判断要返回什么数据
response = responseForPath(r);
} else {
response = new byte[1];
Utils.log("接受到了一个空请求");
}
SocketOperator.socketSendAll(socket, response);
}
}
} catch (IOException ex) {
System.out.println("exception: " + ex.getMessage());
}
}
private static byte[] responseForPath(Request reqeust) {
HashMap<String, Function<Request, byte[]>> map = Route.routeMapAll();
Function<Request, byte[]> function = map.getOrDefault(reqeust.path, Route::route404);
byte[] response = function.apply(reqeust);
return response;
}
}

服务器启动

  • 只要给 Server.run(port) 传一个端口号就行了
public class Main {
public static void main(String[] args) {
System.out.println("hello");
int port = 9000;
Server.run(port);
}
}