ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用JDK内置HttpServer手写轻量级HTML静态服务器

2026/9/2 1:47:00 拓冰建站 浏览量
用JDK内置HttpServer手写轻量级HTML静态服务器 简介一个用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 HttpServer30 行左右无轻量静态服务、内部工具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 MapString, String MIME_MAP new HashMap(); static { MIME_MAP.put(html, text/html; charsetutf-8); MIME_MAP.put(htm, text/html; charsetutf-8); MIME_MAP.put(css, text/css; charsetutf-8); MIME_MAP.put(js, application/javascript; charsetutf-8); MIME_MAP.put(json, application/json; charsetutf-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/svgxml); MIME_MAP.put(ico, image/x-icon); MIME_MAP.put(txt, text/plain; charsetutf-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 htmlhtml lang\zh-cn\headmeta charset\utf-8\title code /title/headbodyh1 code message /h1/body/html; byte[] bytes html.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set(Content-Type, text/html; charsetutf-8); exchange.sendResponseHeaders(code, bytes.length); try (OutputStream os exchange.getResponseBody()) { os.write(bytes); } exchange.close(); } }3.3 启动与验证如果你用 Maven直接在项目根目录执行mvn compile exec:java -Dexec.mainClasscom.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里声明了charsetutf-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响应头里是否带了charsetutf-8HTTP 头没有默认字符集只要响应头里不带浏览器就会猜测编码这很容易猜错。head区域里是否加上meta charsetutf-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?v20250101绕过缓存。对于内部工具来说强制刷新就够了不必专门处理 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; charsetutf-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 分开处理这套基础已经足够支撑你往下走了。本文还有配套的精品资源点击获取