简介:这份资源是一个用Java实现的轻量级HTML服务器,面向希望理解HTTP协议底层原理、Socket编程与线程池应用的Java学习者。它基于Socket通信、线程池、输入输出流及简易HTTP协议构建,仅由两个核心类文件组成,麻雀虽小五脏俱全,适合作为网络编程入门练手或课程设计参考。压缩包共7个文件,包含2个java源码、2个jar包、2个html页面和1个使用说明txt,整体仅26KB,源码与可执行包齐备,便于直接运行或二次打包。启动时在DOS窗口进入jar所在目录,执行java -Djava.ext.dirs=. httpserver.HttpServer [路径] [端口]即可,路径与端口可选,默认端口1234,首页支持index.html。将html文件放入目录并重命名为index.html后,浏览器访问http://localhost:1234即可查看效果。编译环境为JDK1.6,最低需JDK1.5以支持内置线程池。目前已有298人学习,适合想深入理解HTTP服务实现细节的开发者研读源码并动手改造。
1. 一个 Java 文件跑起静态站点:这套 HTML 服务器到底能顶什么用
很多人第一次听到「Java HTML 服务器」,脑子里浮现的是 Tomcat、Jetty 那一整套容器,配置 web.xml、打 war 包、丢进 webapps 目录,折腾半天才看到一个 Hello World。但真实场景里,我们经常遇到的诉求其实特别朴素:手上有一堆写好的 HTML、CSS、JS 静态文件,想用 Java 快速起一个能对外提供 HTTP 服务的进程,不引入任何第三方依赖,一个类、几十行代码就能跑起来。这份「简单的 JAVA HTML 服务器」资源,解决的正是这个场景——它用 JDK 自带的com.sun.net.httpserver.HttpServer或者原生ServerSocket手写 HTTP 协议解析,把静态目录映射成可访问的 URL,适合做本地预览、内网工具页、教学演示、接口联调时的临时静态托管。
它不适合谁?不适合要上生产、要扛并发、要玩 HTTPS 双向认证、要接 Spring 生态的人,那些场景老老实实上成熟容器。但如果你只是想验证一段前端代码、给同事临时分享一个报表页面、或者在离线环境里跑一个不依赖任何 jar 包的迷你服务,这套东西的价值就出来了:零依赖、可读、可改、可嵌入。下面我按「它怎么工作 → 怎么落地 → 坑在哪 → 怎么进阶」的顺序,把这份资源拆开讲透。
2. 从 ServerSocket 到 HttpServer:两条实现路线怎么选
2.1 原生 ServerSocket 手写 HTTP 解析
最原始的做法是自己监听端口、接收连接、读字节流、按 HTTP 协议格式解析请求行和请求头,然后拼响应。这条路线的价值不在于性能,而在于你能彻底看清 HTTP 报文长什么样。核心逻辑是:ServerSocket绑定端口后进入accept()循环,每个连接交给一个线程处理,从InputStream里按行读取,第一行就是GET /index.html HTTP/1.1这样的请求行。
// 原生 ServerSocket 版:最小可运行的静态文件服务器核心 ServerSocket server = new ServerSocket(8080); System.out.println("listening on 8080"); while (true) { Socket socket = server.accept(); // 阻塞等待连接 new Thread(() -> { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); OutputStream out = socket.getOutputStream()) { String requestLine = in.readLine(); // 只取请求行,够用 if (requestLine == null) return; String path = requestLine.split(" ")[1]; // 拿到 /index.html if ("/".equals(path)) path = "/index.html"; File file = new File("webroot" + path); // 映射到本地目录 if (!file.exists()) { out.write("HTTP/1.1 404 Not Found\r\n\r\n".getBytes()); return; } byte[] body = Files.readAllBytes(file.toPath()); String header = "HTTP/1.1 200 OK\r\n" + "Content-Length: " + body.length + "\r\n" + "Content-Type: " + guessType(path) + "\r\n\r\n"; out.write(header.getBytes()); out.write(body); } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) {} } }).start(); }这段代码里几个参数值得说清楚。端口8080是约定俗成的开发端口,改成 80 需要管理员权限。webroot是静态资源根目录,所有请求路径都拼在它后面,这里必须做路径穿越防护,否则../../etc/passwd就能读到系统文件。Content-Type靠guessType根据扩展名推断,.html给text/html,.css给text/css,.js给application/javascript,给错了浏览器会拒绝执行。Content-Length必须和实际字节数一致,否则浏览器会一直等或者截断。
这条路线的好处是零依赖、逻辑透明,JDK 装好就能编译运行。坏处也很明显:HTTP/1.1 的 keep-alive、分块传输、Range 请求、并发连接管理全得自己处理,稍微复杂一点就翻车。我一般只在教学或者需要极致可控的嵌入式场景用它。
2.2 用 JDK 自带 HttpServer 省掉协议解析
JDK 6 之后自带了com.sun.net.httpserver.HttpServer,虽然藏在jdk.httpserver模块里、文档不多,但它把 HTTP 协议解析、连接管理、线程池都封装好了,你只需要注册一个HttpHandler处理路径映射。这是这份资源里更推荐的落地方式,代码量能砍掉一半以上。
// HttpServer 版:注册 handler,静态目录映射 HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); server.createContext("/", exchange -> { String path = exchange.getRequestURI().getPath(); if ("/".equals(path)) path = "/index.html"; File file = new File("webroot", path).getCanonicalFile(); // 关键:校验规范化后的路径仍在 webroot 内,防穿越 if (!file.getPath().startsWith(new File("webroot").getCanonicalPath())) { send(exchange, 403, "Forbidden".getBytes()); return; } if (!file.exists() || file.isDirectory()) { send(exchange, 404, "Not Found".getBytes()); return; } byte[] body = Files.readAllBytes(file.toPath()); exchange.getResponseHeaders().set("Content-Type", guessType(path)); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.setExecutor(Executors.newFixedThreadPool(8)); // 线程池,别用默认 server.start();createContext("/")表示拦截所有路径,exchange对象封装了请求和响应。sendResponseHeaders(200, body.length)里的第二个参数是响应体长度,传 0 表示未知长度、走分块传输,传 -1 表示无响应体。setExecutor这一步很多人漏掉,默认执行器是单线程的,一个慢请求就能把整个服务卡死,换成固定线程池是最低成本的修复。路径穿越防护用getCanonicalFile()规范化后再比对前缀,比字符串contains("..")靠谱得多,因为 URL 编码、符号链接都能绕过简单检查。
两条路线怎么选?要教学、要完全掌控协议细节,用 ServerSocket;要快速落地、代码可维护,用 HttpServer。这份资源两种都有参考实现,实际项目里我九成情况选后者。
3. 静态资源映射与 MIME 类型:让浏览器正确渲染
3.1 目录映射与默认首页规则
静态服务器的核心是「URL 路径 → 本地文件路径」的映射。规则看着简单,细节全是坑。根路径/要映射到index.html,这是 Web 服务器的通用约定,但如果你目录里放的是index.htm或者default.html,就得按优先级依次尝试。目录请求(比如/docs/)要么返回该目录下的默认首页,要么生成文件列表页,要么直接 403,三种策略各有适用场景。
// 默认首页按优先级探测 String[] defaults = {"index.html", "index.htm", "default.html"}; File target = new File(webroot, path); if (target.isDirectory()) { for (String name : defaults) { File candidate = new File(target, name); if (candidate.exists()) { target = candidate; break; } } }这段逻辑要放在路径穿越校验之后、文件读取之前。webroot建议用绝对路径初始化,相对路径在不同启动目录下行为不一致,是排查问题时最常见的玄学来源。我习惯在启动时打印一行webroot resolved to: xxx,出问题时一眼就能看出根目录对不对。
3.2 MIME 类型表与字符编码
浏览器靠Content-Type决定怎么处理响应体。给错了,CSS 不生效、JS 报语法错误、图片显示成乱码。JDK 自带的Files.probeContentType()在部分系统上返回 null 或者不准,生产可用的做法是维护一张扩展名到 MIME 的映射表。
| 扩展名 | MIME 类型 | 常见翻车点 |
|---|---|---|
| .html | text/html; charset=UTF-8 | 漏 charset 导致中文乱码 |
| .css | text/css | 给成 text/plain 样式不生效 |
| .js | application/javascript | 给成 text/javascript 部分浏览器警告 |
| .json | application/json | 给成 text/plain 前端解析失败 |
| .png | image/png | 二进制文件被当文本处理损坏 |
| .svg | image/svg+xml | 漏 +xml 后缀不渲染 |
| .woff2 | font/woff2 | 字体加载失败控制台报 CORS |
字符编码这块,HTML 文件建议在响应头里显式带charset=UTF-8,同时 HTML 内部<meta charset="UTF-8">也要写,双保险。二进制文件(图片、字体、压缩包)必须用字节流读写,一旦用Reader/Writer按字符处理,文件就废了。这是新手最容易踩的坑之一,现象是图片能下载但打不开、字体加载报错。
提示:MIME 表不用追求大而全,覆盖你项目里实际用到的十几种就够,剩下的统一给
application/octet-stream让浏览器走下载。
4. 并发、路径安全与常见故障排查
4.1 线程模型与连接数控制
原生 ServerSocket 版如果每个连接new Thread(),几百个并发就能把线程数打爆,内存飙升、上下文切换开销巨大。正确做法是用线程池,核心线程数按CPU 核数 × 2起步,队列有界,拒绝策略明确。HttpServer 版通过setExecutor传入线程池,效果一样。
// 有界队列 + 明确拒绝策略,避免请求堆积拖垮进程 ThreadPoolExecutor pool = new ThreadPoolExecutor( 4, // 核心线程 16, // 最大线程 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(100), // 有界队列,防止无限堆积 new ThreadPoolExecutor.AbortPolicy() // 满了直接拒绝,快速失败 );参数怎么定?核心线程数看你的请求里有没有阻塞 IO,纯静态文件读取是 IO 密集型,可以适当放大。队列必须有界,无界队列在流量突增时会吃光内存,这是血泪经验。拒绝策略选AbortPolicy让客户端拿到明确失败,比默默排队到超时体验好。
4.2 路径穿越与目录列表泄露
路径穿越是静态服务器最经典的安全问题。攻击者构造/../../../etc/passwd或者 URL 编码变体/..%2f..%2f,如果你的拼接逻辑没做规范化校验,就能读到 webroot 之外的文件。防护要点:先getCanonicalFile()规范化,再判断结果路径是否以 webroot 的规范化路径为前缀,两个条件缺一不可。
目录列表泄露是另一个常被忽视的点。当请求的路径是个目录且没有默认首页时,如果你的实现返回了目录下所有文件名,等于把内部结构暴露给所有人。除非你就是想做文件分享站,否则目录请求一律返回 403 或 404。
4.3 常见故障排查清单
现象:浏览器一直转圈,请求不返回。原因通常是响应头里Content-Length和实际写入字节数不一致,或者忘了调用exchange.close()。解决:核对长度、确保每个分支都关闭 exchange。
现象:中文文件名或中文内容乱码。原因是响应头没带 charset,或者读取文件时用了平台默认编码。解决:统一 UTF-8,响应头显式声明,读文件用Files.readAllBytes走字节。
现象:端口被占用,启动报 BindException。原因是 8080 被其他进程占了。解决:netstat -ano | findstr 8080(Windows)或lsof -i:8080(Linux)找到进程,换端口或杀掉。
现象:本地能访问,局域网其他机器访问不了。原因是绑定地址写成了127.0.0.1而不是0.0.0.0,或者防火墙没放行端口。解决:new InetSocketAddress(8080)默认绑所有网卡,确认防火墙入站规则放行对应端口。
现象:大文件下载中断。原因是没处理 Range 请求或者一次性把整个文件读进内存导致 OOM。解决:流式传输,用Files.copy或缓冲区循环读写,需要断点续传就实现 Range 头解析。
注意:排查顺序永远是「先看日志有没有异常 → 再看端口和绑定地址 → 再看路径映射 → 最后看响应头」,从外到内,别一上来就怀疑代码逻辑。
5. 进阶技巧:把这份服务器改造成能用的工具
5.1 加一层简单的访问日志
没有日志的服务器就是黑匣子,出了问题只能靠猜。加访问日志成本极低,在 handler 入口记录时间、客户端 IP、方法、路径、响应码、耗时,输出到控制台或文件。
long start = System.currentTimeMillis(); // ... 处理请求 ... long cost = System.currentTimeMillis() - start; System.out.printf("%s %s %s %d %dms%n", exchange.getRemoteAddress().getAddress().getHostAddress(), exchange.getRequestMethod(), exchange.getRequestURI().getPath(), exchange.getResponseCode(), cost);getRemoteAddress()拿到客户端地址,getResponseCode()在sendResponseHeaders之后才有值。耗时统计能帮你快速定位是哪个资源慢。这套日志格式借鉴了 Nginx 的 combined 格式,熟手一眼就能读懂。
5.2 支持 Range 请求实现断点续传
视频、大文件下载场景,浏览器会发Range: bytes=0-1023这样的请求头,服务器要返回 206 Partial Content 和Content-Range头。不实现的话,视频没法拖动进度条,大文件下载中断后只能重来。
String range = exchange.getRequestHeaders().getFirst("Range"); if (range != null && range.startsWith("bytes=")) { String[] parts = range.substring(6).split("-"); long start = Long.parseLong(parts[0]); long end = parts.length > 1 && !parts[1].isEmpty() ? Long.parseLong(parts[1]) : file.length() - 1; long len = end - start + 1; exchange.getResponseHeaders().set("Content-Range", "bytes " + start + "-" + end + "/" + file.length()); exchange.getResponseHeaders().set("Accept-Ranges", "bytes"); exchange.sendResponseHeaders(206, len); try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { raf.seek(start); byte[] buf = new byte[8192]; long remaining = len; while (remaining > 0) { int read = raf.read(buf, 0, (int) Math.min(buf.length, remaining)); if (read == -1) break; exchange.getResponseBody().write(buf, 0, read); remaining -= read; } } }RandomAccessFile.seek(start)直接跳到起始位置,避免读整个文件。Accept-Ranges: bytes告诉浏览器支持范围请求。边界情况要处理:start超过文件长度返回 416,end超过文件长度要截断到length - 1。这套逻辑写对了,视频拖动、下载器多线程下载都能正常工作。
5.3 用配置文件替代硬编码
端口、webroot、线程数、默认首页这些参数写死在代码里,每次改都要重新编译,不现实。最简单的做法是读一个server.properties,用Properties类加载,启动时解析。
Properties props = new Properties(); try (InputStream is = new FileInputStream("server.properties")) { props.load(is); } int port = Integer.parseInt(props.getProperty("port", "8080")); String webroot = props.getProperty("webroot", "./webroot"); int threads = Integer.parseInt(props.getProperty("threads", "8"));getProperty的第二个参数是默认值,配置文件缺失或某项没写时兜底。这样同一份代码,换个配置文件就能跑不同环境,部署时不用动代码。
5.4 验证清单:上线前走一遍
改完代码别急着交付,按这个清单过一遍:根路径能返回首页;不存在的路径返回 404 而不是异常堆栈;../路径穿越被拦截返回 403;中文 HTML 不乱码;CSS、JS、图片 MIME 正确;并发压测下线程数稳定不飙升;大文件下载不 OOM;局域网其他机器能访问;日志有输出且格式可读。这九条走完,这套简单的 Java HTML 服务器就真正能用了。
从那以后我每次改完这类服务器代码,都会强制走一遍这份清单,尤其是路径穿越和 MIME 这两项,翻车次数最多。希望帮到你。
本文还有配套的精品资源,点击获取