ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java NIO多线程手写Web服务器:注解控制器与HTTP长连接实现

Java NIO多线程手写Web服务器:注解控制器与HTTP长连接实现 简介一份基于 Java NIO 实现的多线程 Web 服务器实例教学 PDF面向希望深入理解 Java 高性能 I/O、多线程网络编程与手写轻量级 Web 容器的后端开发者也适合作为相关课程设计或项目实践的参考资料。包内共 1 个 PDF 文件大小仅 59KB内容精炼便于快速阅读与对照实践已有 310 人学习/下载。文档从整体设计到代码实现均有覆盖基于 NIO 处理高并发连接通过多线程与线程池提升响应能力支持静态与动态资源获取、Cookie 和 Session 管理、HTTP 长连接及过期连接/会话的定时清理机制。同时展示了类似 Spring MVC 的注解式编程包括 Controller、RequestMapping、RequestParam 等的实现细节方法参数可按名称自动注入支持对象与级联属性绑定模板渲染支持从 request 和 session 域取值项目结构包含 webapp 静态资源根目录、server-config.properties 配置文件和 EchoController.java 控制器示例可据此了解服务器启动、配置、日志记录与请求处理全流程。整体非常适合想要掌握 NIO 编程模型、自定义注解解析及 HTTP 协议应用的开发者作为进阶参考。1. 手写 Web 服务器第一步这个 Java NIO 多线程项目能跑静态资源、注解控制器和长连接被一句“用 Java 手写一个 Web 服务器”卡住的开发者不在少数尤其是准备 Java 面试题或者做课程设计时第一版往往是用 ServerSocket 加线程池的 BIO 模型连接一过百就线程爆炸。这个项目是个基于 Java NIO 的多线程 Web 服务器实例网络层换成 Selector 事件驱动业务层交给线程池顺手把注解式控制器、模板渲染、Cookie/Session 和 HTTP 长连接全做了。它不只演示 NIO 怎么选还演示了一个可用的服务器该怎么分层。适合想拆 NIO 源码的人、课程设计要找参考的人以及面试前想搞明白“长连接到底靠什么维持”的人。下面按原理、用法、避坑、验证的顺序拆开说。2. 并发模型BIO 线程爆炸之后NIO 怎么用两个线程池接住几千连接2.1 一连接一线程的 BIO 为什么必然翻车BIO 的逻辑很直白accept 到一个 Socket就 new 一个线程去处理读写。小规模时确实好写但连接数到了几千线程数也到几千每个线程默认栈空间 1MB光是虚拟内存就吃掉几个 GB再加上线程切换的上下文开销CPU 大量时间花在换线程而不是处理 HTTP 请求上。更难受的是HTTP 长连接场景里大部分连接是空闲的一个 keep-alive 连接挂在那等下一个请求对应线程却要一直阻塞在读操作上这属于典型的“占着线程不干活”。所以这个项目把 IO 等待和业务处理拆开了。网络层只负责“有数据来了通知我”不负责“等数据”。这正是 Java NIO 里 Selector 的用途一个线程可以同时监听几千个 SocketChannel 的 OP_ACCEPT、OP_READ 事件谁有数据就处理谁没有事件就阻塞在 select 上睡觉。2.2 Poller 线程只干一件事监听读事件配置里有POLLER_THREAD_COUNT2意思是开两个线程专职监听客户端读事件。常见做法是主线程 accept 之后把 SocketChannel 注册到某个 Poller 线程的 Selector 上Poller 线程循环调用 select()一旦有通道可读就把这个通道连同数据交给后端的请求处理器线程池。用两个 Poller 而不是一个是为了避免单个 Selector 上注册的连接过多导致 select 返回后处理不过来这也是多 Reactor 模型的简化版。// 伪代码示意Poller 线程的核心循环 while (selector.select(1000) 0) { IteratorSelectionKey iterator selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key iterator.next(); iterator.remove(); if (key.isReadable()) { SocketChannel channel (SocketChannel) key.channel(); // 不在这里做业务只读取字节流并交给工作线程池 processorExecutor.submit(new RequestTask(channel, key)); } } }这段代码的关键在于 select 超时设为 1000 毫秒这是为了给“定时清除过期连接”留出口子。如果一直阻塞在 select() 上连接清理线程就没机会判断哪些连接超时了。Poller 线程本身不做业务解析只负责把可读事件转成任务丢给线程池这样 Poller 的循环永远不被慢业务卡住。2.3 请求处理器线程池业务逻辑和网络 IO 隔离REQUEST_PROCESSOR_THREAD_COUNT4指定了处理具体请求的线程池大小。这个池子里的线程负责解析 HTTP 请求行、请求头、请求体找到对应的 Controller 方法执行业务再写回响应。把网络事件监听和业务处理分开的意义在于假设某个接口查询数据库要 200ms如果 Poller 线程自己执行这 200ms 内它就顾不上其他连接的读事件了其他连接全部排队丢给线程池后Poller 可以立刻回到 select() 继续监听代价只是等待线程池有空闲线程。还有一个容易被忽略的点HTTP 长连接。配置里CONNECTION_EXPIRY_TIME30000表示连接空闲 30 秒就断开CONNECTION_CLEANING_CYCLE30000表示每 30 秒检查一次哪些连接过期。这个清理动作在 BIO 模型里很难做因为阻塞读的线程没法被外部安全打断NIO 模型下通道的注册状态都在 Selector 里清理线程只要关闭超时的 SocketChannelPoller 下次 select 时自然感知到连接失效。3. 注解式控制器仿 Spring MVC 的路由注册、参数绑定和级联对象赋值3.1 通过类路径扫描发现 Controller这个项目最吸引人的地方在于它把 Spring MVC 那套注解搬了进来Controller、RequestMapping、RequestParam、RequestHeader、CookieValue。用法和 Spring MVC 基本一致控制器类上标注Controller方法上标注RequestMapping并指定路径和 HTTP 方法一个 Web 接口就定义好了。与 Spring 用包名扫描不同的是这个项目是遍历 classpath 下的目录来找控制器类的所以它不支持打成 jar 包运行。这个设计取舍在后面避坑章节再展开这里先看注解怎么被消费。启动时框架遍历 classpath 下所有 .class 文件反射读取每个类上的注解把RequestMapping的路径、HTTP 方法、方法参数列表注册进一个路由表。路由表的结构大概是MapRequestKey, MethodRequestKey由请求路径 HTTP 方法组合而成。// 演示控制器完整展示项目里能用到的注解 Controller public class EchoController { private static final DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); // GET /echo?msghello RequestMapping(/echo) public String echo(HttpRequest request, RequestParam(value msg, defaultValue 输入为空) String msg) { LocalDateTime localDateTime LocalDateTime.ofEpochSecond( request.getSession().getLastAccessedTime() / 1000, 0, ZoneOffset.ofHours(8)); request.setAttribute(lastAccessedTime, localDateTime.format(formatter)); request.setAttribute(msg, msg); return test.html; } }这里有个细节值得注意formatter被声明为private static final。控制器实例被多线程并发调用时实例字段和静态字段都是共享的DateTimeFormatter本身是线程安全类所以可以放心用。如果你换成SimpleDateFormat并发下会出现错乱时间甚至抛异常这也是手写 Web 容器最容易踩的线程安全坑。3.2 RequestParam 的参数名解析和类型转换RequestParam的作用是告诉框架“这个参数从请求查询串里取属性名是 msg如果没传就用默认值”。实现时要做的事情比注解本身多得多先拿到方法参数的注解信息再定位到请求参数 Map 里的值最后做字符串到目标类型的转换。// 示意参数绑定的大致实现思路 public Object resolveArgument(MethodParameter parameter, HttpRequest request) { RequestParam rp parameter.getAnnotation(RequestParam.class); String paramName rp.value(); String value request.getParameter(paramName); if (value null || value.isEmpty()) { value rp.defaultValue(); } Class? targetType parameter.getType(); if (targetType Integer.class || targetType int.class) { return Integer.valueOf(value); } if (targetType String.class) { return value; } // 其他类型走 Converter 或 NumberFormat return convert(value, targetType); }注意方法签名里Integer pageSize这样的写法注解的defaultValue是字符串框架要先判断目标类型是Integer再做Integer.valueOf转换。如果转换失败框架会把异常包装成 HTTP 400 还是 500取决于实现但逻辑上必须有一个明确的失败分支而不是让反射直接抛异常。value属性没写的时候还有个隐性规则直接用参数名作为请求参数名比如Integer pageNum默认绑定请求参数pageNum。所以注释里的// GET /page?pageSize10pageNum1才能对得上。3.3 对象参数和级联属性赋值user.data.val 是怎么挂上去的比简单类型参数更复杂的是对象绑定。请求里传user.nameadminuser.passwdadminuser.data.valok方法签名里写String login(User user)框架要能自动把扁平参数组装成嵌套对象 User → Data → val。这依赖 Java 的内省机制先实例化 User 对象再把参数名按点拆成属性路径逐层调用 setter。// 示意级联属性赋值的核心逻辑 Object bindObject(Class? targetType, MapString, String params, String prefix) { Object target instantiate(targetType); for (String key : params.keySet()) { if (!key.startsWith(prefix)) { continue; } String remaining key.substring(prefix.length()); // user.name - name String[] path remaining.split(\\.); setValueByPath(target, path, params.get(key)); } return target; } void setValueByPath(Object obj, String[] path, String value) { Object current obj; for (int i 0; i path.length - 1; i) { current getOrCreateProperty(current, path[i]); // 逐级取属性并创建 } PropertyDescriptor pd new PropertyDescriptor(path[path.length - 1], current.getClass()); pd.getWriteMethod().invoke(current, convertToFieldType(pd.getPropertyType(), value)); }这套逻辑的关键在逐级创建中间对象。user.data.val到第二段时getOrCreateProperty会检查当前对象里data属性是否为 null如果为 null 就调用无参构造器创建一个 Data 实例挂上去否则继续往下。这也解释了为什么被绑定的对象必须有可访问的无参构造器和对应字段的 setter没有 setter 时getWriteMethod()会失败最终表现为整个绑定报错或者字段静默为空。4. 配置、启动与日志server-config.properties 跑起来的完整链路4.1 配置文件参数逐个说项目在 classpath 下读取server-config.properties所有可调参数都在这里。把这些参数和含义理清调试时才不会乱改代码。配置项默认示例含义PORT80服务器监听端口LOG_FILE_STORAGE_PATHE:\日志文件存放目录CONNECTION_EXPIRY_TIME30000长连接空闲过期时间单位毫秒CONNECTION_CLEANING_CYCLE30000清理过期连接的周期单位毫秒SESSION_EXPIRY_TIME30000Session 过期时间单位毫秒SESSION_CLEANING_CYCLE30000清理过期 Session 的周期单位毫秒POLLER_THREAD_COUNT2监听客户端读事件的线程数REQUEST_PROCESSOR_THREAD_COUNT4处理具体请求的线程池大小# 服务器端口 PORT80 # 存储日志文件的路径 LOG_FILE_STORAGE_PATHE:\\ # 连接过期时间单位毫秒 CONNECTION_EXPIRY_TIME30000 # 清理过期连接的周期单位毫秒 CONNECTION_CLEANING_CYCLE30000 # Session 过期时间单位毫秒 SESSION_EXPIRY_TIME30000 # 清理过期 Session 的周期单位毫秒 SESSION_CLEANING_CYCLE30000 # 监听客户端读事件的线程数目 POLLER_THREAD_COUNT2 # 处理具体请求的线程池的大小 REQUEST_PROCESSOR_THREAD_COUNT4连接过期时间和清理周期可以不一致前者决定单个连接空闲多久算超时后者决定多久检查一次。比如把CONNECTION_EXPIRY_TIME设成 10000、CLEANING_CYCLE设成 5000那么一个空闲连接最长可能存活 15 秒左右才能被扫掉因为清理动作发生在周期节点上。Session 清理同理SESSION_EXPIRY_TIME和SESSION_CLEANING_CYCLE之间的这种“延迟误差”在高并发下会影响活跃用户数统计压测时要留意。4.2 BootStrap.run() 启动方式和 webapp 目录约定使用方式极简主类 main 方法里直接调BootStrap.run()但前提是 classpath 下有一个webapp目录它是静态 Web 资源的根路径。HTML、CSS、JS、图片都放这里模板渲染时返回的test.html就是从webapp/test.html读取的。public class Application { public static void main(String[] args) { // 读取 classpath 下的 server-config.properties // 初始化 Poller 线程、请求处理线程池、Session 管理器 // 绑定端口并开始 accept BootStrap.run(); } }启动顺序内部大概是这样的先加载配置文件校验必填项再初始化两个定时器连接清理和 Session 清理然后启动POLLER_THREAD_COUNT个 Poller 线程最后创建请求处理线程池并开始 accept。这里要特别强调目录约定如果直接把项目打成 jar 放服务器上跑webapp目录在 jar 里反射遍历 classpath 的方式拿不到目录下文件列表所以项目必须以 class 文件形式发布目录结构保持原样。4.3 模板正则替换和双日志结构模板渲染用正则实现不是模板引擎。test.html里的${request.msg}、${request.lastAccessedTime}会被替换成 request 域里的属性值支持点路径比如request.user.id会先取 request 的 user 属性再取 user 的 id 属性。正则匹配的表达式大概是\$\{(request|session)\.[a-zA-Z0-9_.]\}匹配到之后把关键字和属性路径提取出来从对应域对象里取值替换。!DOCTYPE html html langen head titleTest/title /head body pEcho: ${request.msg}/p pLast Accessed Time: ${request.lastAccessedTime}/p pimg srcimg/girl.jpg altgirl width320 height480//p /body /html注意模板只能从 request 和 session 域里取数据不能直接访问控制器里的局部变量。如果你在方法里往 request 塞了一个对象模板写${request.user.name}框架会先反射调用request.getAttribute(user)拿到 User 对象再通过getName()取值。中间任何一步取到 null替换结果是什么取决于实现细节但常见做法是替换成空字符串而不是抛异常否则一旦数据缺失整页渲染失败。日志这块用的是java.util.logging内置日志器自定义了格式分两类文件。server-n.log记录服务器运行相关日志比如端口绑定、Poller 启动、线程池初始化、连接清理动作access-n.log记录 HTTP 请求日志包括请求路径、方法、响应状态、耗时。nginx 的 access log 也是类似设计日常排查请求有没有进来、耗时多少看 access 日志就够了线程池状态和连接清理异常看 server 日志。5. 避坑笔记class 文件发布限制、webapp 路径和线程安全的五个坑5.1 项目不支持打包成 jar 运行现象把项目打成 jar 后放到服务器执行控制器全部扫不到访问任何动态路径都是 404。原因框架扫描控制器是通过遍历 classpath 目录下的 .class 文件实现的目录遍历在 jar 包内部是走不通的File.listFiles()拿不到 jar 里的条目。解决以 class 文件形式发布保持webapp、com/test这些目录结构完整如果一定要发布成单个可执行文件需要改造扫描逻辑用JarFileAPI 遍历 jar 条目但项目已经明确不支持老老实实按目录发布最快。5.2 模板路径对不上 webapp 根目录现象控制器返回test.html但浏览器拿到 404。原因返回的字符串是相对于webapp根目录的模板路径如果目录里实际文件叫test.html但 controller 返回test或test.html前多了/解析出来的实际文件路径就不对。解决先确认webapp/test.html真实存在然后启动后在 server 日志里看模板解析出来的完整路径通常日志里会打印“渲染模板: xxx”。另外注意模板里的img srcimg/girl.jpg也是相对 webapp 根目录的浏览器请求的是/img/girl.jpg服务器要能把这个映射到webapp/img/girl.jpg静态资源处理器和动态请求路由别混在一起。5.3 端口 80 在 Linux 下权限不足Windows 下被占用现象BootStrap.run()启动时直接抛Permission denied或者Address already in use。原因Linux 下 1024 以下端口需要 root 权限用普通用户跑必然失败Windows 下 IIS、其他开发服务器很可能占用了 80 端口。解决本地开发把配置改成PORT8080Linux 生产如果非要用 80用 root 启动或者用 iptables/nginx 做端口转发。这个排查顺序要固定先看端口有没有被占用再看当前用户有没有权限别一上来就怀疑代码。5.4 控制器里塞实例字段并发请求数据串了现象两个用户同时请求同一个接口A 用户看到的响应里混进了 B 用户的数据。原因控制器实例是单例的所有请求线程共享同一个对象。如果在控制器里写private String currentUser;这种实例字段请求 A 刚赋值还没渲染完请求 B 又覆盖了A 渲染时就拿到 B 的值。解决控制器里只放static final的线程安全对象比如DateTimeFormatter、常量、只读配置请求级别的数据一律通过方法参数和 request 域的 attribute 传递。血泪经验是哪怕一个看似无害的private MapString, Object temp new HashMap()也会翻车因为 HashMap 本身不线程安全并发 put 还会把内部结构搞坏。5.5 级联属性绑定静默失败对象没有无参构造器或 setter现象POST 表单提交user.nameadminuser.data.valok登录方法里 User 对象的 name 有值但 data 始终是 null。原因级联属性绑定需要逐层创建中间对象getOrCreateProperty碰到没有无参构造器的类就无法实例化或者字段虽然有 getter 但没 setterPropertyDescriptor.getWriteMethod()返回 null框架只能跳过或者抛异常。解决检查 User 类、Data 类是否都有无参构造器所有要绑定的字段是否都有 setter。另外注意这套绑定机制识别的属性名和 getter/setter 的方法是强相关的字段名passwd对应setPasswd如果写成setPassword就对不上。6. 验证与扩展用 curl 和 wrk 确认长连接、Session 清理真实生效6.1 用 curl 验证静态、动态和响应头启动服务器后先用最简单的命令确认活着curl -i http://localhost:8080/test.html正常会返回 200、Content-Type 和 HTML 内容。再看动态接口curl -i http://localhost:8080/echo?msghello响应里 Template 渲染出来的 HTML 中“Echo: hello”应该被替换为 hello。最后验证长连接是否生效用curl -v看响应头里的Connection字段。curl -v -H Connection: keep-alive http://localhost:8080/echo注意观察返回的Connection: keep-alive或Connection: close。如果服务器在响应里明确带出 keep-alive说明长连接逻辑生效如果每次请求都带Connection: close要么是客户端没复用到连接要么是CONNECTION_EXPIRY_TIME时间太短还没来得及复用就被清理掉了。6.2 用 wrk 压测观察线程池和连接清理压测长连接最直观的工具是 wrk一条命令就能看吞吐和延迟分布wrk -t4 -c200 -d30s --keepalive http://localhost:8080/test.html-t4表示 4 个线程-c200表示 200 个并发连接--keepalive要求复用连接。压测时盯着 server 日志里的连接清理记录如果 30 秒压测期间日志里出现大量“clean connection”字样说明CONNECTION_EXPIRY_TIME设得太短空闲连接被频繁回收正常场景长连接清理动作应该是低频的。这个验证特别重要因为长连接如果没生效压测结果看起来还行但真实浏览器场景里每次请求都重新握手性能会明显下滑。验证 Session 过期也有一套先访问/echo让服务器生成 Session然后看日志确认 Session 创建再等一个SESSION_EXPIRY_TIME SESSION_CLEANING_CYCLE的周期看 server 日志是否打印清理记录。从那以后我每次拿到这类手写服务器项目都会先跑一遍 curl 验响应头、跑一遍 wrk 验连接数、再盯着日志等一个清理周期三关全过才敢说这个服务器能上环境。希望帮到你。本文还有配套的精品资源点击获取
返回列表