前面十三篇实现了一个完整的搜索引擎内核:倒排索引、BM25 评分、段文件、崩溃恢复、摘要和高亮。但它只能通过 Java main 方法调用——没有 API,没有界面,用户没法用。
本篇把搜索引擎包装成一个 HTTP 服务,加一个最简单的网页界面,让用户在浏览器中输入查询、看到结果、翻页。同时处理搜索服务必须面对的工程问题:参数校验、超时、错误反馈。
HTTP 搜索端点
JDK 内置 HTTP Server
Java 25 自带 com.sun.net.httpserver.HttpServer,不需要引入 Spring Boot 或其他框架。教学场景下它足够:
1 2 3 4 5 HttpServer server = HttpServer.create(new InetSocketAddress (8080 ), 0 ); server.createContext("/api/search" , this ::handleSearch); server.createContext("/" , this ::handleStatic); server.setExecutor(Executors.newFixedThreadPool(4 )); server.start();
四个线程足以处理教学场景的并发。生产系统会用 virtual thread 或 Netty,但这里不需要。
请求处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 void handleSearch (HttpExchange exchange) throws IOException { if (!"GET" .equals(exchange.getRequestMethod())) { sendError(exchange, 405 , "只支持 GET" ); return ; } Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery()); String q = params.getOrDefault("q" , "" ).trim(); int page = clamp(parseInt(params.get("page" ), 1 ), 1 , 100 ); int size = clamp(parseInt(params.get("size" ), 10 ), 1 , 50 ); String lang = params.get("lang" ); if (q.isEmpty()) { sendJson(exchange, 200 , emptyResult(q)); return ; } if (q.length() > 256 ) { q = q.substring(0 , 256 ); } try { SearchResult result = engine.search(q, page, size, lang); sendJson(exchange, 200 , result); } catch (Exception e) { sendError(exchange, 500 , "搜索内部错误" ); } }
参数校验
参数
默认值
上限
处理
q
必填
256 字符
空 → 空结果;超长 → 截断
page
1
100
< 1 → 1;> 100 → 100
size
10
50
< 1 → 10;> 50 → 50
lang
null
-
无效值 → 忽略过滤
上限不是任意的:page × size = 最多需要计算 Top-5000 条结果。超过这个范围的深翻页在搜索引擎中本身就不是合理的用户行为。
1 2 3 4 5 6 7 8 9 int clamp (int value, int min, int max) { return Math.max(min, Math.min(max, value)); }int parseInt (String s, int defaultValue) { if (s == null ) return defaultValue; try { return Integer.parseInt(s); } catch (NumberFormatException e) { return defaultValue; } }
超时与取消
问题
某些查询可能触发大量 posting list 遍历,耗时超出用户容忍度。搜索服务需要一个超时机制:超过 5 秒没有返回结果,就终止搜索并告知用户。
实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void handleSearch (HttpExchange exchange) throws IOException { CompletableFuture<SearchResult> future = CompletableFuture.supplyAsync( () -> engine.search(q, page, size, lang), searchExecutor); try { SearchResult result = future.get(5 , TimeUnit.SECONDS); sendJson(exchange, 200 , result); } catch (TimeoutException e) { future.cancel(true ); sendError(exchange, 504 , "搜索超时,请缩短查询或添加过滤条件" ); } catch (ExecutionException e) { sendError(exchange, 500 , "搜索内部错误" ); } }
future.cancel(true) 会设置搜索线程的中断标志。搜索循环中需要检查中断:
1 2 3 4 5 6 for (int docId : postingList) { if (Thread.currentThread().isInterrupted()) { throw new SearchCancelledException ("查询被取消" ); } }
不检查中断的话,cancel 不会真正终止搜索——线程会继续跑完,只是结果被丢弃。
错误反馈
错误响应格式
1 2 3 4 5 { "error" : true , "status" : 504 , "message" : "搜索超时,请缩短查询或添加过滤条件" }
错误分类
状态码
场景
用户看到的消息
200
正常结果(包括空结果)
结果列表或"未找到相关结果"
400
参数格式错误
“参数格式不正确”
405
非 GET 请求
“只支持 GET 请求”
504
搜索超时
“搜索超时,请缩短查询”
500
索引损坏或未知异常
“搜索服务暂时不可用”
空结果(0 hits)不是错误——返回 200 + 空数组 + 提示。用户搜到 0 条结果是正常的,不应该看到红色错误页面。
JSON 序列化
教学场景不引入 Jackson/Gson,手写最小 JSON 输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 void sendJson (HttpExchange exchange, int status, SearchResult result) throws IOException { StringBuilder json = new StringBuilder (); json.append("{" ); json.append("\"query\":" ).append(jsonString(result.query())).append("," ); json.append("\"totalHits\":" ).append(result.totalHits()).append("," ); json.append("\"page\":" ).append(result.page()).append("," ); json.append("\"results\":[" ); for (int i = 0 ; i < result.items().size(); i++) { if (i > 0 ) json.append("," ); ResultItem item = result.items().get(i); json.append("{" ); json.append("\"title\":" ).append(jsonString(item.title())).append("," ); json.append("\"url\":" ).append(jsonString(item.url())).append("," ); json.append("\"snippet\":" ).append(jsonString(item.snippet())).append("," ); json.append("\"score\":" ).append(item.score()); json.append("}" ); } json.append("]}" ); byte [] body = json.toString().getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type" , "application/json; charset=utf-8" ); exchange.sendResponseHeaders(status, body.length); exchange.getResponseBody().write(body); exchange.close(); } String jsonString (String s) { return "\"" + s.replace("\\" , "\\\\" ) .replace("\"" , "\\\"" ) .replace("\n" , "\\n" ) .replace("\r" , "\\r" ) .replace("\t" , "\\t" ) + "\"" ; }
网页界面
单页 HTML
一个 index.html 文件,通过 / 路径提供。包含搜索框、结果列表和翻页按钮,用原生 fetch API 调用搜索端点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 <!DOCTYPE html > <html lang ="zh" > <head > <meta charset ="UTF-8" > <title > 搜索引擎</title > <style > body { font-family : sans-serif; max-width : 800px ; margin : 0 auto; padding : 20px ; } .search-box { display : flex; gap : 8px ; margin-bottom : 20px ; } .search-box input { flex : 1 ; padding : 8px ; font-size : 16px ; } .search-box button { padding : 8px 16px ; } .result { margin-bottom : 16px ; } .result .title { font-size : 18px ; } .result .title a { color : #1a0dab ; text-decoration : none; } .result .url { color : #006621 ; font-size : 14px ; } .result .snippet { color : #545454 ; font-size : 14px ; } .result em { font-style : normal; font-weight : bold; } .error { color : #d93025 ; } .pager { margin-top : 20px ; } .empty { color : #70757a ; } </style > </head > <body > <h1 > 搜索引擎</h1 > <div class ="search-box" > <input type ="text" id ="query" placeholder ="输入查询..." autofocus > <button onclick ="doSearch(1)" > 搜索</button > </div > <div id ="results" > </div > <div id ="pager" class ="pager" > </div > <script > document .getElementById ('query' ).addEventListener ('keydown' , e => { if (e.key === 'Enter' ) doSearch (1 ); }); async function doSearch (page ) { const q = document .getElementById ('query' ).value .trim (); if (!q) return ; try { const resp = await fetch ( `/api/search?q=${encodeURIComponent (q)} &page=${page} &size=10` ); const data = await resp.json (); if (data.error ) { document .getElementById ('results' ).innerHTML = `<div class="error">${data.message} </div>` ; document .getElementById ('pager' ).innerHTML = '' ; return ; } if (data.results .length === 0 ) { document .getElementById ('results' ).innerHTML = `<div class="empty">未找到与 "${q} " 相关的结果</div>` ; document .getElementById ('pager' ).innerHTML = '' ; return ; } let html = '' ; for (const r of data.results ) { html += `<div class="result"> <div class="title"><a href="${r.url} ">${r.title} </a></div> <div class="url">${r.url} </div> <div class="snippet">${r.snippet} </div> </div>` ; } document .getElementById ('results' ).innerHTML = html; let pagerHtml = '' ; if (page > 1 ) pagerHtml += `<button onclick="doSearch(${page-1 } )">上一页</button> ` ; pagerHtml += `第 ${page} 页` ; if (data.results .length === 10 ) pagerHtml += ` <button onclick="doSearch(${page+1 } )">下一页</button>` ; document .getElementById ('pager' ).innerHTML = pagerHtml; } catch (e) { document .getElementById ('results' ).innerHTML = `<div class="error">网络错误,请稍后重试</div>` ; } } </script > </body > </html >
静态文件服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void handleStatic (HttpExchange exchange) throws IOException { String path = exchange.getRequestURI().getPath(); if ("/" .equals(path)) path = "/index.html" ; InputStream is = getClass().getResourceAsStream("/static" + path); if (is == null ) { sendError(exchange, 404 , "页面不存在" ); return ; } byte [] body = is.readAllBytes(); String contentType = path.endsWith(".html" ) ? "text/html; charset=utf-8" : path.endsWith(".css" ) ? "text/css" : path.endsWith(".js" ) ? "application/javascript" : "application/octet-stream" ; exchange.getResponseHeaders().add("Content-Type" , contentType); exchange.sendResponseHeaders(200 , body.length); exchange.getResponseBody().write(body); exchange.close(); }
验证
场景
操作
预期
正常查询
浏览器搜索 “java”
结果列表,标题和摘要高亮
空结果
搜索 “xyznonexistent”
“未找到相关结果”,200 状态码
坏输入
page=abc
降级为 page=1,正常返回
超长查询
输入 300 字符
截断为 256 字符,正常搜索
超时
构造大量 posting 的查询
504 + “搜索超时”
翻页
点击下一页
第 2 页结果,与第 1 页不重复
过滤
加 lang=zh
只返回中文文档
后端崩溃
关闭索引文件
500 + “搜索服务暂时不可用”
当前局限
没有 CORS 支持——前端和 API 在同一个端口,不需要跨域
没有请求限流——教学场景没有恶意流量
JSON 手写容易出错——第 15 篇迁移后可以引入 Jackson
界面极其简陋——没有样式优化、没有自动补全、没有搜索历史
没有 HTTPS——教学场景在 localhost 运行
练习
用浏览器完成一次完整的搜索流程:输入查询 → 查看结果 → 翻页 → 换查询词
用 curl 直接调用 API,观察 JSON 响应格式
构造一个触发超时的查询(如果数据量太小无法触发,手动在搜索循环中加 Thread.sleep 模拟)
在搜索框输入 <script>alert(1)</script>,验证页面不弹窗(查询词被正确编码)
将 page 设为 101,验证被 clamp 到 100
延伸阅读
JDK 内置 HTTP Server:com.sun.net.httpserver.HttpServer Javadoc
MDN Web Docs:Fetch API
OWASP:REST Security Cheat Sheet