前面十三篇实现了一个完整的搜索引擎内核:倒排索引、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 运行

练习

  1. 用浏览器完成一次完整的搜索流程:输入查询 → 查看结果 → 翻页 → 换查询词
  2. 用 curl 直接调用 API,观察 JSON 响应格式
  3. 构造一个触发超时的查询(如果数据量太小无法触发,手动在搜索循环中加 Thread.sleep 模拟)
  4. 在搜索框输入 <script>alert(1)</script>,验证页面不弹窗(查询词被正确编码)
  5. 将 page 设为 101,验证被 clamp 到 100

延伸阅读

  • JDK 内置 HTTP Server:com.sun.net.httpserver.HttpServer Javadoc
  • MDN Web Docs:Fetch API
  • OWASP:REST Security Cheat Sheet