跳转到内容

9:网络编程基础

后端Banner

欢迎来到Java网络编程的世界! 本次任务将带你从经典的Java底层网络API出发,感受HTTP客户端封装技巧与设计模式的演进,并最终结合当下最具科技感的LLM(大语言模型)API,动手打造一个带安全签名与鉴权的CLI AI Agent客户端。

在本题中,你需要使用Maven来管理和构建你的项目,java版本使用17或更高版本,推荐使用java21。

早期的java与外界进行网络交互,通常使用经典的java.net.HttpURLConnectionjava.nio.channels.SocketChanneljava.net.DatagramSocket及相关的类。其实你已经在第五题使用过HttpURLConnection了。请你回顾一下这个题,并了解一下SocketChannel、DatagramSocket。

思考一下,HttpURLConnection和SocketChannel有什么区别和联系?如今现代Java后端开发为什么很少再直接在业务代码中使用HttpURLConnection?它存在什么局限性?

在新版本java中,标准库有一个更现代的类java.net.http.HttpClient,它的API比上述更好用,同时也支持一些更现代的特性。试着用HttpClient向https://test.glimmer.org.cn/echo/发送GET请求,看看它会返回什么内容?

现在尝试添加一个自定义HTTP Header,看看它会返回什么内容?

再来尝试一下发送POST请求吧!请求体内可以试着放一个普通字符串,然后试着放一个json?

请求体内如果放json数据的话,记得要加一个HTTP header:Content-Type: application/json,这样服务器才能知道你发送的是json数据而不是普通的字符串。

如果使用json的话,建议使用FastjsonGsonJackson等json序列化/反序列化库,不要使用纯字符串拼接或者利用正则表达式来构造和解析!

需要提交代码+运行截图!其中打印返回内容时,请让json输出得更美观、优雅一点,方便阅读,而不是全部压缩到一行去。

💡提示:对于Fastjson,可使用JSON.parseObject(json).toJSONString(JSONWriter.Feature.PrettyFormatWith4Space)来美观输出原始json数据。

本题中提供的URL如果出现无法访问等问题,请随时联系出题人!联系方式见本题最底部。

有一类软件专门用于从互联网上下载内容,我们一般称此类软件为下载器。比如B站的up主们以前经常推荐的IDM(Internet Download Manager)NDM(Neat Download Manager)FDM(Free Download Manager)

请你利用HttpClient实现一个简单的下载器,要下载的文件是https://test.glimmer.org.cn/re/the_last_supper.png。你需要补充如下代码,最后你需要提交代码和运行截图。

要下载的文件大小为2.85MB,但设置了60KB/s的限速,所以下面的代码在正常情况下需要约50s才能够下载完成。

package com.example;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
public class SimpleDownloader {
public static final String TEST_URL = "https://test.glimmer.org.cn/re/the_last_supper.png";
private static final String SAVE_PATH = "small.bin";
public static void main(String[] args) {
// 创建一个HttpClient
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.ALWAYS).build()) {
// 下载计时
LocalDateTime startTime = LocalDateTime.now();
// TODO 构建request并发送这个request,需要添加header:“Authorization: Bearer glimmer_recruit”,否则返回401错误。你的代码应写在TODO和END TODO之间。
// END TODO
// 结束计时
System.out.println("耗时:" + ((double) Duration.between(startTime, LocalDateTime.now()).toMillis() / 1000) + "s");
} catch (IOException e) {
System.err.println("出现错误!请检查你的网络连通性,或者咨询出题人服务器是否正常。");
e.printStackTrace();
} catch (InterruptedException e) {
System.err.println("下载操作被中断!");
e.printStackTrace();
}
}
}

这一部分不做硬性要求,不做也不影响提交这道题,但做了可加分。做下面两个Task时不用遵守上面的TODO和END TODO限制,但都需要提交代码和运行截图。

在上面的例子中,下载一个文件约需要50s,在这50s内,程序没有任何输出,那我们怎么才能知道这个程序是在正常下载还是卡住了呢?请试着修改你的代码,在控制台输出中实时打印下载进度条,这样会使你写的程序拥有更好的用户体验。

为什么市面上一些下载器的下载速度要比其他软件更快?其实是因为它们会尽量使用多线程来下载文件。这是什么意思呢?假设你接了一根水管到家里,而一根水管最多每秒流1L水。哪怕你家主管道每秒能流10L水,你也只能干瞪眼,白白浪费了9L的容量;但如果你从水厂接了10根独立的水管。每根管子还是1L/秒,但10根加起来就是10L/秒。你并不是让水流变快了,而是把主管道的“配额”给占满了。

多线程下载快的原因不仅仅是上述这样,但这个比喻能让你很好的理解多线程下载。还记得吗?上面给的链接,限速是60KB/s,但假如是多线程下载的话,用4个线程的话就能达到240KB/s的下载速度(假设你的最大带宽大于240KB/s)。

请你了解多线程(Multithreading)(还可以进一步了解异步CompletableFuture),并尝试将你的下载器改造成多线程下载器,通过设置4个线程,完成分块并发下载并组合为完整可打开的文件,能让你的下载时间从50s缩短至12s!

4个线程就足够了,不鼓励设置超过10个线程!如果发现返回了HTTP状态码567,就说明你发送请求太快太多了,你的设备将被封禁一段时间,请等待一段时间后再试。

原理要点

  • 发送 HEAD 请求获取文件大小(Content-Length),以及是否支持分块下载(Accept-Ranges: bytes)。
  • 根据分配的线程数,切分每个线程要下载的字节区间,利用Request Header中添加Range: bytes=start-end并发请求不同的分块。
  • 将各分块数据正确写入同一个目标文件的对应偏移量处(提示:可了解RandomAccessFile或NIO FileChannel)。

在第三方库中,Okhttp已然成为了首选的java http客户端。相比于HttpClient,Okhttp提供了更强大的功能,更简单易用。请你了解这个框架,并学会使用此框架完成基础的GET请求和POST请求,然后试着完成下面的题目。Okhttp的依赖已给出:

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>

有时我们请求一个API,同时也需要将数据传递给服务器,但怎么将数据传递给服务器呢?在前面的过程中,你已经知道了可以利用POST请求将你的数据转换为json格式,进而发送到服务器。然而,还有另一种常见的方式——查询字符串。

一天,你在Bilibili上看到一条有意思的视频,你想把这条视频分享给你的好朋友们,你会去点击“分享”按钮。在Bilibili网页版中,当你点击“分享”按钮时,Bilibili会将指向这条视频的链接复制到剪贴板。让我们来观察一下这条链接长什么样吧。以下是一个示例链接:

https://www.bilibili.com/video/BV12yN264EwR/?share_source=copy_web&vd_source=3848787f4de6668e568f67f4434057ac

经过前面的学习,你应该已经了解了:

  • https:// 是指采用https协议
  • www.bilibili.com是Bilibili的域名
  • /video/BV12yN264EwR/指向你访问的视频路径

那么最后面的?share_source=copy_web&vd_source=3848787f4de6668e568f67f4434057ac是什么呢?

其实,这就是查询字符串(Query String)。查询字符串以问号?开头,将每个键值对用&连接,并将键和值用=连接起来。所以,这条查询字符串向服务器传递了:

  • share_source:copy_web
  • vd_source:3848787f4de6668e568f67f4434057ac

思考一下,查询字符串有什么好处?

那么我们该如何构建查询字符串呢?你可能会想到使用字符串和+拼接字符串URL,比如:

String share_source = "copy_web";
String vd_source = "3848787f4de6668e568f67f4434057ac";
String url = "https://www.bilibili.com/video/BV12yN264EwR/" + "?" +
"share_source" + "=" + share_source + "&" +
"vd_source" + "=" + vd_source;

但是,查询字符串经常需要URL编码,如果你的查询字符串中包含中文、空格或&=等特殊符号,且没有经过URL编码,请求立刻返回400错误。这样拼接字符串有时也容易漏掉?&等拼接符号。那么有没有更优雅的方式呢?答案是有的。请你学习okhttp3.HttpUrl.Builder及相关的类,试着用HttpUrl.Builder改写上面的代码。

要求提交代码,但不用提交运行截图。

有一天,你需要访问一个外部API集合,比如你会访问如下API:

路径请求方法解释
/usersPOST新增用户
/users/123DELETE删除id为123的用户
/users/123PUT更新id为123的用户信息
/users/123GET查询id为123的用户信息

而这些开放的API由于可以间接操纵到数据库中的数据,所以不能让所有人都能直接访问,只能让经授权的用户/程序来访问。怎么才能授权呢?假设这些API要求你在请求中携带一个http header,并在这个header中带上你的凭证,服务器以此来确认你的身份。你可能会写出如下代码:

package com.example;
import com.alibaba.fastjson2.JSON;
import okhttp3.*;
import java.io.IOException;
public class UserManager {
private static final String host = "https://test.glimmer.org.cn";
private static final String auth = "Bearer glimmer_recruit";
private static final OkHttpClient client = new OkHttpClient();
public record User(long id, String nickname) {}
// 新增用户
public static boolean addUser(User user) {
Request request = new Request.Builder()
.url(host + "/users")
.header("Authorization", auth)
.post(RequestBody.create(JSON.toJSONString(user), MediaType.get("application/json")))
.build();
return execute(request);
}
// 删除目标id对应的用户
public static boolean removeUser(long id) {
Request request = new Request.Builder()
.url(host + "/users/" + id)
.header("Authorization", auth)
.delete()
.build();
return execute(request);
}
// 更新目标用户的信息
public static boolean updateUser(User user) {
Request request = new Request.Builder()
.url(host + "/users/" + user.id)
.header("Authorization", auth)
.put(RequestBody.create(JSON.toJSONString(user), MediaType.get("application/json")))
.build();
return execute(request);
}
// 根据id查询对应的用户
public static User getUser(long id) {
Request request = new Request.Builder()
.url(host + "/users/" + id)
.header("Authorization", auth)
.get()
.build();
try (Response res = client.newCall(request).execute()) {
if (res.isSuccessful()) {
ResponseBody body = res.body();
if (body != null) return JSON.parseObject(body.string(), User.class);
}
} catch (IOException e) {
System.err.println("请求出现异常,请检查网络可达性");
}
return null;
}
// 简单提取重复代码,用于发送请求,并返回是否成功
private static boolean execute(Request request) {
try (Response res = client.newCall(request).execute()) {
return res.isSuccessful();
} catch (IOException e) {
System.err.println("请求出现异常,请检查网络可达性");
}
return false;
}
}

https://test.glimmer.org.cn/users 说明

实现了上面所述的对用户的增删改查的API,直接根据上述API说明请求即可。如果没有正确的header,则一律返回401错误。

如果有正确的header,那么新增用户和删除用户永远返回200,并返回一个提示成功的json;对于修改用户和查询用户,返回一个以json表示的User对象,其中id为传入的数据,nickname则永远为Glimmer。

这些API没有连接到任何真实数据库,此API仅作为此题测试使用,没有实际功能。

由于上面有4种不同的请求,所以最后我们写了4遍.header("Authorization", auth)。目前来看貌似问题不大,但假如你要请求的API更多,每次你都需要在请求上手动添加header,这种方式看起来很不优雅。有没有什么更优雅的方式呢?答案是有的。

有一种设计模式叫做责任链模式(Chain of Responsibility),其经常应用于框架的开发。请你学习并理解责任链模式的核心理念,然后了解一下Okhttp.Interceptor,并思考问题:

为什么 OkHttp 要把各类功能(网络IO、重定向、日志、重试、自定义处理)都设计成一个个Interceptor拼接在链条上?

尝试改进上述java代码,利用Interceptor对请求进行切面干预,统一为每一个发出的Request追加header,不再需要每次创建Request时手动添加header。

要求提交代码,并尝试编写测试代码,然后提交运行截图

当下,AI Agent也是一个热门的方向。简单来说, Agent(智能体) 就是一种不仅能“做解答”,还能“拿工具去干活”的AI。你给它一个复杂的最终目标,它能够自主规划步骤、调用外部工具、检查执行结果,并且根据报错或环境变化不断修正,直到把任务彻底办完。你可能听说过Claude Code,它可以帮助你写代码,改bug,还能做其他很多事情,而Claude Code就是一个Agent。尝试动手编写一个简单的Agent吧!

以下两个任务只需要完成其一即可。推荐有能力的同学完成任务一。如果任务一对你来说还有点难,则可以尝试完成任务二。

你需要模型服务商提供的API才能完成本题。强烈推荐DeepSeek,完成本题所花的钱还没一瓶矿泉水贵,同时DeepSeek的模型也非常好用。如果想要寻求免费的API,可尝试百度的ENRIE-Lite智谱的GLM-4.7-Flash谷歌的Gemini甚至Ollama本地运行LLM,但模型质量可能不及预期。

本题需要提交代码和运行截图!提交代码时请注意不要把自己的API KEY提交上去了

请你尝试从零开始实现一个Agent。你的Agent应当能从标准输入中获取用户的提示词,然后与LLM进行交互,并为LLM提供一个可以执行终端命令的工具供它调用,然后将AI的最终回答输出到标准输出。

任务清单:

  • 为你的Agent写一份System Prompt
  • 尝试使用HttpClient或Okhttp发起网络请求,与LLM进行交互
  • 为LLM提供执行终端命令的能力

加分项:

  • 让你的Agent支持流式输出,不用等到AI把内容全部生成完毕再显示结果,实现在命令行里“字一个个蹦出来”的酷炫效果!
  • LLM可能会执行一些危险的命令,比如删除文件的命令,可尝试为你的Agent添加拦截机制,如果Agent想要执行这些命令,需要先经过用户同意。
  • 在与LLM API交互时,可能会出现各种状况。比如网络连接突然断开,或是API返回“服务器繁忙,请稍后再试”,可尝试想办法妥善处理这些意外情况。

参考文档:DeepSeek API 文档OpenAI API 文档

请你完成一个简单的文件读写Agent,并尝试使用一下你自己写的Agent(比如让它读取本地的一个txt文档,并让它总结文档内容)。以下是示例代码,你需要修改和补充代码中的TODO和END TODO部分。

注:以下代码与API交互时使用OpenAI的Chat Completions API格式。

package com.example;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import okhttp3.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class SimpleFileAgent {
// TODO 请修改BASE_URL、API_KEY和MODEL
private static final String API_KEY = "your_api_key";
private static final String BASE_URL = "https://api.deepseek.com/chat/completions";
private static final String MODEL = "deepseek-v4-flash";
private static final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build();
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print(">>> 请输入指令 (例如:读取 test.txt,总结重点并写入 summary.txt): ");
String userInput = scanner.nextLine();
String finalAnswer = runAgent(userInput);
System.out.println("\n--- [Agent 最终输出] ---");
System.out.println(finalAnswer);
}
// Agent控制流
public static String runAgent(String userPrompt) {
// 传递上下文,这里系统提示词写的比较简单
List<Map<String, Object>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", "你是一个能读写本地文件的助手,根据用户需求精准调用工具完成任务。"));
messages.add(Map.of("role", "user", "content", userPrompt));
while (true) {
// 与LLM进行交互,获得回复
JSONObject aiResponse = callChatApi(messages);
JSONObject messageObj = aiResponse.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message");
// 将模型当前回答添加到上下文
messages.add(messageObj);
// 判断是否发起了工具调用,如果发起了则尝试调用外部函数
JSONArray toolCalls = messageObj.getJSONArray("tool_calls");
if (toolCalls != null && !toolCalls.isEmpty()) {
for (int i = 0; i < toolCalls.size(); i++) {
JSONObject toolCall = toolCalls.getJSONObject(i);
// 获取当前调用的工具信息
String callId = toolCall.getString("id");
JSONObject function = toolCall.getJSONObject("function");
String funcName = function.getString("name");
JSONObject args = JSON.parseObject(function.getString("arguments"));
System.out.println("-> [Agent 正在执行工具] " + funcName + "(" + args + ")");
// 执行工具调用
String toolResult = executeFileTool(funcName, args);
// 将外部函数的运行结果塞入上下文,交给下次while循环
messages.add(Map.of(
"role", "tool",
"tool_call_id", callId,
"name", funcName,
"content", toolResult
));
}
} else {
// 若不调用工具,说明全部执行完成,直接获取最终生成的文本
return messageObj.getString("content");
}
}
}
// 利用Okhttp和Fastjson,与AI服务商提供的API进行交互
private static JSONObject callChatApi(List<Map<String, Object>> messages) {
// 组装请求体
Map<String, Object> requestMap = new HashMap<>();
requestMap.put("model", MODEL);
requestMap.put("messages", messages);
requestMap.put("tools", getToolsSchema());
// 这是你需要发送的请求体
String jsonBody = JSON.toJSONString(requestMap);
JSONObject result = null;
// TODO 请尝试向BASE_URL发起POST请求,并添加header,header中要填上你的API_KEY,然后将响应体中的字符串转换为JSONObject对象,再通过return返回。
// END TODO
return result;
}
// 返回一个List,内容包含LLM可调用的外部函数
private static List<Map<String, Object>> getToolsSchema() {
return List.of(
Map.of(
"type", "function",
"function", Map.of(
"name", "read_file",
"description", "读取指定本地路径的文件内容",
"parameters", Map.of(
"type", "object",
"properties", Map.of(
"file_path", Map.of(
"type", "string",
"description", "目标文件系统路径")),
"required", List.of("file_path")))),
Map.of(
"type", "function",
"function", Map.of(
"name", "write_file",
"description", "将生成的内容保存或覆盖写入到指定的本地文件",
"parameters", Map.of(
"type", "object",
"properties", Map.of(
"file_path", Map.of(
"type", "string",
"description", "保存的目标文件名"),
"content", Map.of(
"type", "string",
"description", "要写入文件的具体内容")),
"required", List.of("file_path", "content")))));
}
// 为LLM提供简单的文件读写函数
private static String executeFileTool(String name, JSONObject args) {
try {
if ("read_file".equals(name)) {
String filePath = args.getString("file_path");
return Files.readString(Path.of(filePath));
} else if ("write_file".equals(name)) {
String filePath = args.getString("file_path");
String content = args.getString("content");
Files.writeString(Path.of(filePath), content);
return "文件写入成功: " + filePath;
}
} catch (IOException e) {
return "工具执行错误(读取/保存异常): " + e.getMessage();
}
return "未知工具名";
}
}
  1. 将代码托管在GitHub、Gitlab或Gitee等平台,并设置仓库为公开(public)。
  2. 在项目根目录的README.md中写下:本题的问答、思考和思考题等仅需用文字阐述的题目的答案。
  3. 向仓库上传你的代码和运行截图等,并在README.md中展示你的运行截图,并作相应阐述。
  4. 向我们提交仓库的URL地址。提交点这里

祝你编码愉快,我们期待在提交列表中看到你的优秀答案!

出题人:出题人头像  moriko

QQ:3580235054

邮箱:3580235054@qq.com