简介一款基于套接字原理、线程池与输入输出流实现的简易Java轻量级Web服务器适合已掌握Java基础语法、刚接触网络编程的初学者也适合想深入理解HTTP协议底层处理细节、对网络服务实现感兴趣的开发者。包内共7个文件包括两个核心Java源码HttpServer与HttpConnection、对应可运行jar包、两个演示用HTML页面以及一份使用说明文档整体RAR压缩包仅26KB。目前已有298人学习下载。实现虽然精简但两个类即可完成端口监听、请求解析、静态文件读取、线程分配与响应返回等完整流程用户可在命令行使用java -Djava.ext.dirs. httpserver.HttpServer启动服务自由指定HTML路径与端口默认端口为1234首页默认为index.html。源码内附且基于JDK1.6环境编译稍加修改就能重新打包运行是理解网络编程、线程池与HTTP协议如何协作的极佳微型样例。1. 简单的Java HTML服务器不装Tomcat也能在5分钟内搭起静态服务对一个开发机来说一个简单的Java HTML服务器其实是最被低估的小工具。当你需要把打包好的HTML报告发给内网同事、给客户演示一个交互原型、或者调试前端构建产物时为这点事装一个Tomcat或Spring Boot项目完全是大炮打蚊子。用JDK自带的com.sun.net.httpserver包几十行代码就能写一个只做一件事的服务器按路径把磁盘上的HTML、CSS、JS吐给浏览器。它适合正在学Java基础的人理解HTTP协议和IO流也适合做内部小工具的工程师解决临时文件分发。这篇文章会从零写一个可以打jar包、可以参数化启动、可以丢到内网跑一段时间的Java HTML服务器并把端口、MIME映射、路径安全、线程池这些参数讲透。直接给出可复现的代码和判断标准让你看完整理就能照着搭一个也知道哪些地方会在上线那一刻翻车。2. HttpServer最小实现先让文件能按路径吐出去2.1 选型内置HttpServer、Tomcat、Spring Boot差距在哪动手之前先明确边界。JDK自带的HttpServer不是Servlet容器不实现javax.servlet规范也没有过滤器链、上下文路径、JSP这类概念。它只做HTTP协议最底层的事接收请求、回调你的Handler、把字节写回客户端。正因为抽象层级低它启动只要几十毫秒内存占用可以压到几十兆非常适合纯静态资源分发和简单的GET接口。Tomcat和Spring Boot在正经Web应用里更有优势但对「把几个HTML文件分享给别人看」这类需求反而要处理webapps目录、application.yml、端口冲突这些额外负担。下面是三者的对比方案启动速度依赖体积适合场景学到的内容JDK内置HttpServer1秒零依赖静态文件、临时服务、内网工具HTTP协议、IO、线程池Tomcat数秒需要安装Servlet/JSP应用部署Servlet规范、Web容器Spring Boot数秒依赖庞大正式业务系统、接口开发生态、AOP、自动装配我一般把这个小服务器当作Java基础面试题的实践载体它同时用到java.nio.file、线程池、HTTP状态码和流的关闭时机比背八股文更能看出一个人有没有真正写过服务端代码。2.2 最小代码把URI映射成磁盘文件核心逻辑很直白接收请求取出URI里的路径以根目录为基准找到对应文件读进来原样回写。下面是第一个可运行版本去掉它之后所有进阶功能都在此基础上加。import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpExchange; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; public class SimpleHtmlServer { public static void main(String[] args) throws IOException { int port Integer.parseInt(System.getProperty(server.port, 8080)); Path root Paths.get(System.getProperty(server.root, .)).toAbsolutePath().normalize(); HttpServer server HttpServer.create(new InetSocketAddress(port), 0); server.createContext(/, exchange - { String path exchange.getRequestURI().getPath(); Path file root.resolve(path.substring(1)).normalize(); if (!file.startsWith(root) || !Files.isRegularFile(file)) { exchange.sendResponseHeaders(404, -1); exchange.close(); return; } byte[] body Files.readAllBytes(file); exchange.getResponseHeaders().set(Content-Type, text/html; charsetutf-8); exchange.sendResponseHeaders(200, body.length); try (OutputStream out exchange.getResponseBody()) { out.write(body); } }); server.start(); System.out.println(simple html server running at http://localhost: port); System.out.println(serving root: root); } }逐段看几个关键点。HttpServer.create(new InetSocketAddress(port), 0)的第二个参数是backlog连接队列长度0表示使用系统默认值对临时服务没有调优必要。createContext(/, handler)注册了一个匹配所有路径的处理器。exchange.getRequestURI().getPath()拿到的是不含查询参数的路径部分比如/index.html?a1会得到/index.html。路径拼接和校验用了两行很关键root.resolve(path.substring(1))把去掉开头斜杠的相对路径拼到根目录下.normalize()会解析掉..这类相对路径符号最后用file.startsWith(root)判断解析后的路径是否还在根目录范围内。这一步是静态文件服务器最基本的安全线少了它请求/../../etc/passwd就能把服务器上任意文件读出来。至于为什么已经是normalize后还要再校验第3章会展开讲。2.3 main入口端口和根目录做成可配参数上面对代码里用System.getProperty读server.port和server.root这种做法比直接读main方法参数更便于和脚本、IDE配合。两个参数都有默认值端口默认8080根目录默认当前工作目录。# 指定端口9000、根目录为web目录 java -Dserver.port9000 -Dserver.root./web SimpleHtmlServer # 指定绝对路径启动 java -Dserver.port9000 -Dserver.root/opt/my-site/web SimpleHtmlServer注意-Dserver.root如果传相对路径是相对于进程的工作目录不是相对于jar所在目录。在IDE里跑和在部署脚本里跑工作目录往往不同因此根目录解析容易变成黑匣子。我的习惯是启动后立刻打印serving root: /absolute/path这一行部署后先看一眼它确认服务器实际在服务哪个目录比猜要快得多。这个版本的服务器能跑通最小场景但直接拿出去给别人用会碰到不少问题所有文件都按text/html返回CSS和JS会被浏览器当成HTML解析访问/时返回404中文文件名可能乱码没有默认文档。下一章把这些补齐。3. 让页面真正能看MIME映射、默认文档与路径安全3.1 MIME映射CSS变成下载、JS不执行是为什么第一个不能忍的问题请求/css/style.css时服务器返回的Content-Type是text/html。浏览器拿到一个CSS文件但Content-Type说是HTML常见表现是样式全部丢失页面变成纯文本排版application/javascript的脚本也可能直接不执行。解决方法是根据文件扩展名返回正确的MIME类型。import java.util.HashMap; import java.util.Map; 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(woff2, font/woff2); MIME_MAP.put(txt, text/plain; charsetutf-8); } private static String getContentType(String path) { int dot path.lastIndexOf(.); if (dot 0 || dot path.length() - 1) { return application/octet-stream; } String ext path.substring(dot 1).toLowerCase(); return MIME_MAP.getOrDefault(ext, application/octet-stream); }这段静态映射覆盖了静态网页常用的资源类型。文本类资源html、css、js、json、txt统一带charsetutf-8避免浏览器用本地编码猜测导致中文乱码。图片和字体不带charset因为二进制内容没有字符集概念加了反而可能被某些严谨的解析器忽略。application/octet-stream是一个兜底值代表「我不知道这是什么类型」。浏览器遇到这个值通常直接下载而不是展示。线上出现「图片能打开、SVG图标变成下载文件」这类问题八成是MIME表里少了对应扩展名在表里补一行即可。有人会偷懒把所有未知类型都映射成text/plain这样图片会被当作文本显示成一堆乱码字节体验更差不建议这样做。3.2 默认文档与URL解码访问/时返回index.html静态服务器一个约定俗成的行为是访问目录时返回该目录下的index.html。访问/等同于访问/index.html访问/docs/等同于访问/docs/index.html。在Handler开头加一段路由改写String path exchange.getRequestURI().getPath(); // 访问目录时自动补上index.html if (path.endsWith(/)) { path path index.html; } else if (path.equals(/)) { path /index.html; }注意顺序先判断endsWith(/)再判断equals(/)否则根路径会被漏掉。补上这一层后用户输入http://localhost:8080/就能直接看到页面。URL解码是一个容易被忽视的坑。浏览器对中文和空格会做百分号编码比如中文文件名报告.html在请求行里是/%E6%8A%A5%E5%91%8A.html。此时exchange.getRequestURI().getPath()在不同JDK版本下的行为不完全一致有的直接返回解码后的字符串有的保留百分号编码。稳妥做法是用getRawPath()拿原始形式再统一按UTF-8解码import java.net.URLDecoder; import java.nio.charset.StandardCharsets; String rawPath exchange.getRequestURI().getRawPath(); String path URLDecoder.decode(rawPath, StandardCharsets.UTF_8);解码要包在try-catch里因为非法的百分号序列会抛IllegalArgumentException这时直接返回400即可。这一层处理好了server.root目录下无论中文文件名还是带空格的目录都能被正确映射。3.3 两点安全补丁路径穿越和目录列表前面用file.startsWith(root)做的校验有个漏洞如果根目录是/opt/web请求路径/../web2/secret.txt经过归一化后得到/opt/web2/secret.txt这个路径的确以/opt/web开头但实际已经跳出根目录进入了web2。正确的判断应该先取父目录再比或者用.getParent()参与校验Path absoluteFile file.toAbsolutePath().normalize(); Path absoluteRoot root.toAbsolutePath().normalize(); if (!absoluteFile.getParent().startsWith(absoluteRoot)) { exchange.sendResponseHeaders(403, -1); exchange.close(); return; }在Windows环境上还要注意大小写不敏感的问题C:\Web和c:\web在WindowsPath的startsWith比较中会被视为不同最好统一调用toAbsolutePath().normalize()后再比较。这个路径穿越的校验在安全测试里几乎是必经项写的时候马虎不得。目录列表是另一个双刃剑需求。内网工具里经常期望访问/files/能看到目录下有哪些文件而不是404。实现方式是在默认文档不存在时遍历目录生成一个简单的HTML页面if (!Files.isRegularFile(file)) { ListString links Files.list(file.getParent()) .map(p - p.getFileName().toString()) .sorted() .map(name - a href\ name \ name /abr/) .toList(); byte[] body String.join(\n, links).getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set(Content-Type, text/html; charsetutf-8); exchange.sendResponseHeaders(200, body.length); try (OutputStream out exchange.getResponseBody()) { out.write(body); } return; }这段代码隐含一个致命问题如果目录里恰好有10000个文件这个页面会有很长的HTML同时也把服务器文件结构暴露给了任何能访问到端口的人。我的做法是给这个功能加开关默认关闭只有内网工具场景才通过-Denable.directory.listtrue打开。如果服务要暴露在非可信网络目录列表和路径穿越校验都要按公网标准对待。4. 打包与部署一个jar起服务脚本参数化运行4.1 用Maven shade插件打可执行jar本地IDE里能跑通只算完成一半。要把它变成能丢到服务器上一条命令启动的工具需要用Maven把类打成可执行jar。最简单的方式是maven-jar-plugin但那样只能打包自己的类一旦后续引入第三方库就会在运行时抛NoClassDefFoundError。更省心的是maven-shade-plugin它把依赖合并进同一个jar产物是真正意义上的单个文件。build finalNamesimple-html-server/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClassSimpleHtmlServer/mainClass /transformer /transformers /configuration /execution /executions /plugin /plugins /buildfinalName决定了jar文件名mainClass指向带main方法的类。执行mvn clean package后target目录下会生成simple-html-server.jar。用java -jar simple-html-server.jar启动时MANIFEST.MF里的Main-Class告诉JVM入口在哪。这个配置要放在pom.xml的buildplugins节点下JDK版本建议8以上因为代码里的lambda和java.nio.file都依赖Java 8。4.2 目录约定web资源、jar与日志怎么摆部署时我习惯用一套固定目录结构避免脚本里到处是相对路径/opt/simple-html-server/ ├── bin/ │ └── start.sh ├── lib/ │ └── simple-html-server.jar └── web/ ├── index.html ├── css/ └── js/lib放jarweb放静态资源bin放启动脚本。日志直接交给进程的输出流由启动脚本或systemd负责收集。这套结构的好处是升级时只替换lib里的jarweb资源单独备份职责清晰。对静态文件服务器来说启动参数里的JVM内存不需要给太大-Xms64m -Xmx256m足够。一次请求最多也就读一个文件进内存除非你的HTML单文件有几GB否则256M上限完全够用。内存给太大会让它在低配机器上启动变慢反而不值。4.3 跨平台启动脚本与systemd托管Linux部署时一个健壮的start.sh长这样#!/usr/bin/env bash APP_HOME$(cd $(dirname $0)/.. pwd) exec java -Xms64m -Xmx256m \ -Dserver.port${PORT:-8080} \ -Dserver.root${APP_HOME}/web \ -jar ${APP_HOME}/lib/simple-html-server.jar关键在APP_HOME的计算cd $(dirname $0)/..先切到脚本所在目录的上一级再pwd拿绝对路径。无论从哪个目录调用这个脚本APP_HOME都指向/opt/simple-html-server。如果不这样做从/root目录执行./bin/start.sh时server.root./web会解析成/root/web然后一脸懵地发现找不到文件。这个坑我踩过一次后来所有脚本都统一用这套目录推导方式。线上环境更重要的是把进程托管起来。写一个systemd服务单元[Unit] DescriptionSimple Html Server Afternetwork.target [Service] Typesimple ExecStart/opt/simple-html-server/bin/start.sh Restarton-failure RestartSec3 Userwww-data [Install] WantedBymulti-user.target保存到/etc/systemd/system/simple-html-server.service后执行systemctl daemon-reload和systemctl enable --now simple-html-server。Restarton-failure保证进程异常退出后3秒内自动拉起Userwww-data指定运行账户避免用root跑Web服务。有一类需求不需要走这套部署流程目标机器没有JDK只有JRE。JDK 8之后JRE和JDK区分没那么明显了但还是要确认机器上有java命令。如果执行java -version报command not found先检查环境变量配置确认JAVA_HOME指向了正确路径再怀疑打包问题。给没有Java环境的机器部署时我会直接打包一个带JRE的运行时比如用jlink裁出最小运行环境但这属于另一套方案简单HTML服务器场景里先确保目标机有Java 8即可。5. 避坑简单Java HTML服务器最常翻车的5个点5.1 中文页面出现锟斤拷CSS选择器选不中中文类名现象HTML文件里是正常中文浏览器打开变成锟斤拷一类的乱码或者CSS里用中文类名的样式失效。原因两层。第一Content-Type里没带charsetutf-8时浏览器按平台默认编码解析Windows下常按GBK读UTF-8文件中文必然乱。第二第3章的MIME映射漏掉了charset只写了text/css浏览器对无charset的CSS也会猜测编码一旦猜错选择器里的中文全废。解决HTML文件本身保存为UTF-8无BOM格式MIME映射里对文本类资源统一加charsetutf-8。HTML文档里再补一个meta charsetutf-8兜底。三层都对齐后中文路径、中文内容、中文类名都不会再出问题。这个问题在Java基础面试里也常被拿来问编码转换理解了getBytes和new String的默认编码行为就不难回答。5.2 端口被占用Address already in use: bind现象启动时抛java.net.BindException: Address already in use: bind服务器起不来。原因8080端口已被其他进程占用。常见占用者包括别的Java服务、nginx、或者上一次启动没杀干净的僵尸进程。解决先查端口占用情况再决定是换端口还是清理进程。# Linux / macOS lsof -i :8080 # Windows netstat -ano | findstr 8080确认占用进程PID后判断是否可以安全结束然后用-Dserver.port9000换一个端口启动。如果是自己上次启动的进程残留直接kill掉比换端口更干净。另外HttpServer.create(new InetSocketAddress(port), 0)的端口参数如果传0会让内核随机分配一个空闲端口这适合做测试但不适合固定服务。5.3 浏览器一直转圈请求挂在pending不结束现象页面能收到响应内容但浏览器标签页一直转圈网络面板显示请求pending几十秒。原因sendResponseHeaders的第二个参数传了-1表示「没有响应体」此时服务器不会自动关闭连接必须由Handler手动调用exchange.close()。如果响应体长度与声明值不一致也会导致连接既不结束也不被复用。解决严格按照HTTP头声明的body长度写入。正确的写法是byte[] body Files.readAllBytes(file); exchange.getResponseHeaders().set(Content-Type, getContentType(path)); if (body.length 0) { exchange.sendResponseHeaders(200, -1); exchange.close(); return; } exchange.sendResponseHeaders(200, body.length); try (OutputStream out exchange.getResponseBody()) { out.write(body); }用try-with-resources包裹输出流方法退出时流自动flush并close。sendResponseHeaders传正数长度时HttpServer会按准确长度管理连接内容写完连接回到可复用状态。零字节响应特殊处理为-1再close避免客户端因声明长度和实际写入不一致而挂起。5.4 改了HTML但浏览器看到的还是旧页面现象文件内容确实改了重启服务器了浏览器刷新后页面还是老样子看起来像玄学。原因两层。第一浏览器HTTP缓存静态资源默认可能被缓存尤其图片和CSS。第二如果web资源是Maven构建产物IDE重新构建时可能把src/main/resources里同名文件覆盖了target目录你改的文件根本没被复制过去。解决先在浏览器按CtrlShiftR强制刷新排除缓存再确认服务器实际读取的磁盘路径。开发调试阶段我通常在Handler里加一行响应头exchange.getResponseHeaders().set(Cache-Control, no-cache);或者更保守的Cache-Control, no-store这样每次刷新都绕过缓存能看到最新文件。确认路径的话看启动时打印的serving root这一行再手动ls那个目录看文件是否更新。多数「改了没生效」问题根源是服务目录和工作目录不一致。5.5 目录请求返回404但文件明明就在那现象访问http://host:8080/docs/得到404但docs/index.html确实存在。原因第3.2节的默认文档逻辑只处理了/和以/结尾的路径。如果请求是/docs不带斜杠endsWith(/)为false于是被当作文件路径去找docs这个文件目录不存在404。解决把无扩展名的路径也当作目录请求处理。判断逻辑改成如果路径不是以斜杠结尾且路径对应的路径不存在文件、不存在扩展名没有.则补斜杠和index.html。最简单有效的写法Path file root.resolve(path.substring(1)).normalize(); if (Files.isDirectory(file) !path.endsWith(/)) { exchange.getResponseHeaders().set(Location, path /); exchange.sendResponseHeaders(302, -1); exchange.close(); return; }先用302重定向到带斜杠的URL再走默认文档逻辑。这样/docs会先变/docs/再变/docs/index.html行为符合浏览器和用户的双重预期。重定向比内部改写路径更合适因为地址栏的URL也会同步更新后续相对路径的CSS、JS引用才能解析正确。6. 交付前的最后一步并发调优与curl验证6.1 线程池改为可配置参数一直没提并发但HttpServer默认的executor是单线程的一个请求的处理过程中后续请求会在accept队列里等待。对于临时分享的小文件影响不大一旦有人打开页面同时加载图片、CSS、JS和字体七八个请求串行排队页面就会明显变慢。改法是在start()之前显式设置线程池import java.util.concurrent.Executors; int threads Integer.parseInt(System.getProperty(server.threads, 8)); server.setExecutor(Executors.newFixedThreadPool(threads));setExecutor必须在server.start()之前调用否则不生效。线程数不是越大越好静态文件服务器的主要开销在磁盘读和网络写线程过多反而增加上下文切换。8到16个线程对内网几十人同时访问完全够用-Dserver.threads16留个参数口子遇到压力再调。6.2 用curl验证HTTP状态、响应头和耗时部署完成后别急着开浏览器先用curl把关键指标验一遍。# 验证状态码、耗时和响应体大小 curl -s -o /dev/null \ -w HTTP %{http_code} 耗时 %{time_total}s 下载 %{size_download}B\n \ http://localhost:8080/index.html # 验证响应头里的Content-Type curl -sI http://localhost:8080/css/style.css第一条命令的三项指标一次输出状态码应该200耗时对本地静态文件应该在几十毫秒以内下载大小和文件实际大小一致。第二条命令用-I只拿响应头重点检查Content-Type是否正确拼出来。如果CSS那行返回text/css; charsetutf-8说明MIME映射和URL解码都没问题。要做并发冒烟测试可以用ab或者wrk这类压测工具一条命令模拟10个并发、1000次请求ab -n 1000 -c 10 http://localhost:8080/看两个指标Failed requests必须为0Requests per second在同机测试时低也是正常的因为瓶颈通常在磁盘读取。压测要控制并发数不要拿大流量打生产机器。6.3 加一行日志让访问记录可追溯这个小服务器的定位是内网工具不上log4j用标准输出就够了。在Handler里记几项关键信息请求方法、路径、状态码、耗时。long start System.nanoTime(); // ... 现有处理逻辑 ... long costMs (System.nanoTime() - start) / 1_000_000; System.out.println(exchange.getRequestMethod() path - exchange.getResponseCode() ( costMs ms));配合systemd的journal日志可以用journalctl -u simple-html-server --since 10 minutes ago查看最近访问记录。排查「谁在什么时间访问了什么文件」这种问题时这行日志比任何调试器都直接。把线程池、curl验证、访问日志这三件事做完这个简单的Java HTML服务器才算真正从「本地能跑」变成「内网敢用」。回头看我写这个小工具的过程最大的教训是不要默认本机跑通就等于部署没问题工作目录、端口占用、编码这三座大山每一个都让我在交付时多花过半小时。好在它们都有明确的检查顺序按「看启动日志、看curl响应头、看状态码」排下来九成问题能在三分钟内定位。希望这篇整理能帮你避开同样的弯路少踩几个坑。本文还有配套的精品资源点击获取