简介:一个用Java实现的简易HTTP服务器,基于Socket通信、线程池、输入输出流和基础HTTP协议,麻雀虽小五脏俱全。适合Java初学者或对网络底层感兴趣的开发者,用来理解Web服务器从启动到响应请求的完整链路。压缩包共7个文件,包含2个Java源文件、2个可直接运行的jar包、2个示例HTML页面以及1份使用说明txt,整体仅26KB,结构清晰。已有297人学习下载。通过源码可以学习HttpServer类如何完成监听与多线程处理,HttpConnection类如何解析请求并返回页面;通过运行jar包,能直观观察在浏览器中访问localhost页面被服务端输出落盘的过程,还能按需修改源码并重新打包,做出带自定义路径与端口参数的微型Web服务,是动手实践的不错起点。 最近在整理一个内部工具时,被一个很简单的问题卡住了:团队需要一个能在内网跑起来的 HTTP 服务器,专门用来预览生成的 HTML 报表,既不想引入 Tomcat 或者 Spring Boot 这种重型框架,又希望用 Java 直接控制整个生命周期。于是花了半天时间,用 JDK 自带的HttpServer手写了一个极简的 HTML 静态服务器。这个项目不算复杂,但整个过程把 HTTP 协议、Java 网络编程和 IO 流相关的知识点串了一遍,非常适合 Java 新手、准备面试的人,以及需要快速在本地起一个 web 服务做演示的开发者。这篇就记录一下完整思路和落地方案。
1. 整体设计思路:为什么不用 Spring Boot
很多人一听说“Java 写 HTTP 服务器”,第一反应就是直接上 Spring Boot。确实,Spring Boot 三两行代码就能起一个 web 服务,但问题是它解决的问题和今天的场景根本不是一个维度。我只是要在一台内网机器上静态托管几个 HTML 文件,没有数据库、没有鉴权、没有 controller、没有复杂的请求路由,引入 Spring Boot 就意味着引入一整条依赖链,启动一个几百兆的应用只是为了返回几个静态页面,资源和心智负担都比较离谱。
还有一类方案是手写ServerSocket,自己解析 HTTP 请求报文、构造响应头、处理 keep-alive。这条路对“理解 HTTP 协议底层”非常有价值,但作为一个日常工具来用,自己解析 HTTP 协议会碰到特别多边界情况,比如Content-Length的截断、Chunked编码、URL 解码等,调试成本不低。所以我选了 JDK 内置的com.sun.net.httpserver.HttpServer,它是 JDK 自带的一个轻量级 HTTP 服务器实现,不需要任何第三方依赖,既不用解析协议细节,又能精确控制每个请求的处理逻辑。
这个方案的取舍逻辑很简单:中间态。它比ServerSocket封装得多,比 Spring Boot 轻量得多。对“返回 HTML 文件”这个单一诉求来说,这是性价比最高的选择。
下面对比一下常见的几条技术路线。
| 方案 | 代码量 | 第三方依赖 | 适用场景 |
|---|---|---|---|
| ServerSocket 手写 | 300 行以上 | 无 | 学习 HTTP 协议原理 |
| JDK HttpServer | 30 行左右 | 无 | 轻量静态服务、内部工具 |
| Spring Boot | 几十行,但工程结构庞大 | 大量 | 完整 Web 应用、微服务 |
| Netty | 多,且引入异步模型 | Netty 全家桶 | 高并发网关、自定义协议 |
可以看出,在“简单”“HTML”“服务器”这三个关键词同时出现的场景下,JDK 自带的HttpServer几乎是最佳答案。
2. 核心原理:一个 HTTP 请求进来之后发生了什么
在写代码之前需要先把底层的逻辑吃透。整个服务器的核心模型并不复杂:在某个端口上监听 TCP 连接,接收 HTTP 请求文本,解析出请求方法、URL、Header 和 Body,然后根据业务逻辑生成 HTTP 响应文本,通过同一个 TCP 连接写回去。
当你用浏览器访问http://192.168.1.10:8080/report.html时,浏览器实际发送出去的请求报文大致是这样的:
GET /report.html HTTP/1.1 Host: 192.168.1.10:8080 Connection: keep-alive User-Agent: Mozilla/5.0 (...) Accept: text/html,...这些文本经过 TCP 层传到你的 Java 进程。HttpServer 这个封装层已经帮我们完成了对请求文本的解析,你拿到的HttpExchange对象里已经拆好了请求方法、路径和 Header。你需要干的事情很简单:读出请求的路径,找到本地对应的 HTML 文件,构造一个Content-Type响应头,把文件字节和响应头写回给浏览器。
这个过程中最核心的一个点是Content-Type和Content-Length。浏览器无法通过文件后缀推断服务器返回的是什么,它只看响应头。如果你返回Content-Type: text/plain,浏览器会直接把 HTML 源代码显示在屏幕上,而不是渲染成页面。如果你不设置Content-Length或者是算错了长度,浏览器会一直等待后续数据过来,导致页面加载不出来。
3. 实操:完整实现一个可用的 HTML 服务器
下面直接进入正题,我把完整的实现过程拆开讲。这里展示的是一个精简但功能完整的版本,支持静态 HTML、CSS、JS 和图片文件的托管。
3.1 项目目录结构
html-server/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/demo/server/ │ │ ├── SimpleHttpServer.java │ │ └── StaticFileHandler.java │ └── resources/ │ └── static/ │ ├── index.html │ ├── report.html │ ├── css/style.css │ └── js/main.js └── pom.xml如果你的电脑上还没有安装 Maven,可以纯靠javac编译这个项目,因为代码本身不依赖任何第三方库。不过用 Maven 组织结构更清晰,后面如果有需要加依赖也方便。
3.2 核心代码实现
先来看服务器入口,负责启动和配置HttpServer实例。
package com.demo.server; import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; import java.util.concurrent.Executors; public class SimpleHttpServer { public static void main(String[] args) throws Exception { int port = 8080; // 创建 HttpServer 实例,绑定端口,backlog 设为 0 表示使用系统默认值 HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); // 注册根路径的处理器,所有请求都交给 StaticFileHandler 处理 server.createContext("/", new StaticFileHandler()); // 设置线程池,用于处理并发请求 server.setExecutor(Executors.newFixedThreadPool(4)); // 启动服务器 server.start(); System.out.println("[INFO] HTML服务器已启动: http://localhost:" + port); } }然后是StaticFileHandler,这是真正干活的类,负责把磁盘上的 HTML 文件读出来,加上响应头,写回给客户端。
package com.demo.server; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.HashMap; import java.util.Map; public class StaticFileHandler implements HttpHandler { // 静态文件根目录,相对于项目 resources 目录 private static final String STATIC_ROOT = "src/main/resources/static"; // 常见文件后缀对应的 MIME 类型 private static final Map<String, String> MIME_MAP = new HashMap<>(); static { MIME_MAP.put("html", "text/html; charset=utf-8"); MIME_MAP.put("htm", "text/html; charset=utf-8"); MIME_MAP.put("css", "text/css; charset=utf-8"); MIME_MAP.put("js", "application/javascript; charset=utf-8"); MIME_MAP.put("json", "application/json; charset=utf-8"); MIME_MAP.put("png", "image/png"); MIME_MAP.put("jpg", "image/jpeg"); MIME_MAP.put("jpeg", "image/jpeg"); MIME_MAP.put("gif", "image/gif"); MIME_MAP.put("svg", "image/svg+xml"); MIME_MAP.put("ico", "image/x-icon"); MIME_MAP.put("txt", "text/plain; charset=utf-8"); } @Override public void handle(HttpExchange exchange) throws IOException { // 1. 拿到请求路径,比如 /report.html 或 /css/style.css String requestPath = exchange.getRequestURI().getPath(); // 2. 防止目录穿越攻击,确保解析后的路径在静态根目录内 Path basePath = Paths.get(STATIC_ROOT).toRealPath(); Path resolvedPath = basePath.resolve(requestPath.substring(1)).normalize(); // 3. 安全检查:normalize 后的路径必须以 basePath 开头 if (!resolvedPath.startsWith(basePath)) { sendError(exchange, 403, "Forbidden"); return; } // 4. 如果请求路径是 /,默认返回 index.html if (resolvedPath.endsWith("/") || requestPath.equals("/")) { resolvedPath = resolvedPath.resolve("index.html"); } File file = resolvedPath.toFile(); // 5. 文件不存在则返回 404 if (!file.exists() || file.isDirectory()) { sendError(exchange, 404, "Not Found"); return; } // 6. 根据文件后缀确定 Content-Type String fileName = file.getName(); int dotIndex = fileName.lastIndexOf('.'); String extension = dotIndex >= 0 ? fileName.substring(dotIndex + 1).toLowerCase() : ""; String contentType = MIME_MAP.getOrDefault(extension, "application/octet-stream"); // 7. 写入响应头。Content-Length 是必须的,否则浏览器会一直等待请求结束 exchange.getResponseHeaders().set("Content-Type", contentType); exchange.sendResponseHeaders(200, file.length()); // 8. 把文件内容写入响应体 try (OutputStream os = exchange.getResponseBody(); FileInputStream fis = new FileInputStream(file)) { byte[] buffer = new byte[8192]; int count; while ((count = fis.read(buffer)) != -1) { os.write(buffer, 0, count); } } exchange.close(); } private void sendError(HttpExchange exchange, int code, String message) throws IOException { String html = "<!doctype html><html lang=\"zh-cn\"><head><meta charset=\"utf-8\"><title>" + code + "</title></head><body><h1>" + code + " " + message + "</h1></body></html>"; byte[] bytes = html.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); exchange.sendResponseHeaders(code, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } exchange.close(); } }3.3 启动与验证
如果你用 Maven,直接在项目根目录执行:
mvn compile exec:java -Dexec.mainClass="com.demo.server.SimpleHttpServer"如果你用的是纯 javac,编译加运行是下面这两行:
javac -encoding UTF-8 -d out src/main/java/com/demo/server/*.java java -cp out com.demo.server.SimpleHttpServer启动之后,终端会输出“HTML服务器已启动”。这时候在浏览器地址栏输入http://localhost:8080,就能看到index.html被正确渲染了。
我用一个包含中文的 HTML 页面实际测了一下,只要文件本身是 UTF-8 编码,Content-Type里声明了charset=utf-8,中文字符完全不会出现乱码。这里有一个关键的坑:如果你用文本编辑器改了文件,但编辑器默认保存成 GBK 编码,那不管你响应头怎么写都救不回来,HTML 文件本身必须统一为 UTF-8。
4. 实操中的几个常见问题与排查
代码看着简单,但部署到不同环境、不同浏览器里,仍然会碰到下面这些典型问题。每一个我都踩过或者看身边的同学踩过,整理成速查表。
4.1 端口被占用
启动时如果报了java.net.BindException: Address already in use: bind,说明 8080 端口已经被其他进程占用了。最常见的是本机已经有程序占用了这个端口。
排查和解决方式:
# 在 Windows 上查看哪个进程占了 8080 端口 netstat -ano | findstr 8080 # 在 macOS 或者 Linux 上 lsof -i :8080确认占用进程后,要么换一个端口(改SimpleHttpServer.java里的port变量),要么直接杀掉占用进程。在实际工作中我倾向于换成 8088 或者 8000,因为 8080 实在太容易被其他开发工具占用了。
4.2 中文乱码
这是一个高频问题,而且出现原因不只一个。按优先级排查:
- HTML 文件本身是否以 UTF-8 编码保存?用编辑器打开看看右下角编码格式,如果不是 UTF-8,另存为时改成 UTF-8。
Content-Type响应头里是否带了charset=utf-8?HTTP 头没有默认字符集,只要响应头里不带,浏览器就会猜测编码,这很容易猜错。<head>区域里是否加上<meta charset="utf-8">?虽然响应头已经声明了,但 HTML 页面里的 meta 标签在本地以file://方式打开、或者服务器响应头丢失时,会成为最后一道保险。
4.3 为什么图片加载不出来
如果 HTML 页面能出来,但图片、CSS、JS 都 404 或者显示乱码,大概率是资源路径写错了。比如页面里写的相对路径是./images/logo.png,但是磁盘上真正的路径是src/main/resources/static/images/logo.png。我在实际调试时发现,路径问题占了这类问题的大半。建议在浏览器按 F12 打开开发者工具,看一下Network面板里具体哪个资源返回了 404,直接就能看到实际请求的 URL 和相对路径的偏差。
4.4 修改 HTML 后不生效
浏览器默认会缓存静态资源。你改完了index.html,刷新页面看到的还是旧内容,这非常常见。解决办法:在浏览器里按Ctrl + F5强制刷新;或者临时在 URL 后面加个版本参数,比如index.html?v=20250101,绕过缓存。对于内部工具来说,强制刷新就够了,不必专门处理 HTTP 缓存头。
4.5 目录穿越漏洞
我在代码里专门做了normalize()和startsWith()的双重检查,这是有真实原因的。如果用户构造请求GET /../../etc/passwd,没有这个检查的服务器就会把宿主机的敏感文件暴露出去。在写这种静态文件服务器时,toRealPath()会把符号链接也解析成真实路径,只有normalize()是不够的。这一点是安全红线,不能省。
5. 扩展思路:从静态服务器到动态 API
这个服务器虽然以静态 HTML 托管为核心,但拓展成支持简单动态接口也很容易。HttpServer本身就是为处理 HTTP 协议设计的,不是只能返回文件。
我在这几天的使用过程中尝试加了一个/api/health接口,用来做存活检查。代码非常简单:
server.createContext("/api/health", exchange -> { String response = "{\"status\":\"ok\"}"; exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); exchange.sendResponseHeaders(200, response.getBytes(StandardCharsets.UTF_8).length); try (OutputStream os = exchange.getResponseBody()) { os.write(response.getBytes(StandardCharsets.UTF_8)); } });这样这个服务器就同时具备了两个能力:静态文件托管 + 轻量 API 返回 JSON。对内部工具而言,这样的组合已经覆盖了大部分使用场景。
我个人在实际使用中的体会是:这个项目最值得学习的不是代码本身,而是“如何判断一个需求应该用什么复杂度去解决”的思维习惯。很多人一上来就用重框架,维护成本陡增;也有的人执着于手写一切,连基础协议都要自己造轮子。对一个目标明确的轻量场景,找到一个恰到好处的封装层,才是最舒服的开发状态。如果后续你想继续深挖,可以考虑把静态资源加载改成从 classpath 读取,做成真正的可执行 jar;或者加上简单的路由分发,把 GET 和 POST 分开处理,这套基础已经足够支撑你往下走了。
本文还有配套的精品资源,点击获取