9:网络编程基础
欢迎来到Java网络编程的世界! 本次任务将带你从经典的Java底层网络API出发,感受HTTP客户端封装技巧与设计模式的演进,并最终结合当下最具科技感的LLM(大语言模型)API,动手打造一个带安全签名与鉴权的CLI AI Agent客户端。
在本题中,你需要使用Maven来管理和构建你的项目,java版本使用17或更高版本,推荐使用java21。
任务一:初识网络编程
Section titled “任务一:初识网络编程”早期的java与外界进行网络交互,通常使用经典的java.net.HttpURLConnection、java.nio.channels.SocketChannel、java.net.DatagramSocket及相关的类。其实你已经在第五题使用过HttpURLConnection了。请你回顾一下这个题,并了解一下SocketChannel、DatagramSocket。
思考一下,HttpURLConnection和SocketChannel有什么区别和联系?如今现代Java后端开发为什么很少再直接在业务代码中使用HttpURLConnection?它存在什么局限性?
任务二:上手网络编程
Section titled “任务二:上手网络编程”基础实践:API数据交互
Section titled “基础实践:API数据交互”在新版本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的话,建议使用Fastjson、Gson或Jackson等json序列化/反序列化库,不要使用纯字符串拼接或者利用正则表达式来构造和解析!
需要提交代码+运行截图!其中打印返回内容时,请让json输出得更美观、优雅一点,方便阅读,而不是全部压缩到一行去。
💡提示:对于Fastjson,可使用JSON.parseObject(json).toJSONString(JSONWriter.Feature.PrettyFormatWith4Space)来美观输出原始json数据。
本题中提供的URL如果出现无法访问等问题,请随时联系出题人!联系方式见本题最底部。
挑战实践:实现一个下载器
Section titled “挑战实践:实现一个下载器”有一类软件专门用于从互联网上下载内容,我们一般称此类软件为下载器。比如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(); } }}优化与提升:改进你的下载器
Section titled “优化与提升:改进你的下载器”这一部分不做硬性要求,不做也不影响提交这道题,但做了可加分。做下面两个Task时不用遵守上面的TODO和END TODO限制,但都需要提交代码和运行截图。
Task 1(难度:中等)
Section titled “Task 1(难度:中等)”在上面的例子中,下载一个文件约需要50s,在这50s内,程序没有任何输出,那我们怎么才能知道这个程序是在正常下载还是卡住了呢?请试着修改你的代码,在控制台输出中实时打印下载进度条,这样会使你写的程序拥有更好的用户体验。
Task 2(难度:较高)
Section titled “Task 2(难度:较高)”为什么市面上一些下载器的下载速度要比其他软件更快?其实是因为它们会尽量使用多线程来下载文件。这是什么意思呢?假设你接了一根水管到家里,而一根水管最多每秒流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)。
任务三:深入网络编程
Section titled “任务三:深入网络编程”在第三方库中,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:
| 路径 | 请求方法 | 解释 |
|---|---|---|
| /users | POST | 新增用户 |
| /users/123 | DELETE | 删除id为123的用户 |
| /users/123 | PUT | 更新id为123的用户信息 |
| /users/123 | GET | 查询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。
要求提交代码,并尝试编写测试代码,然后提交运行截图
实践:构建你的CLI AI Agent
Section titled “实践:构建你的CLI AI Agent”当下,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 "未知工具名"; }}- 将代码托管在GitHub、Gitlab或Gitee等平台,并设置仓库为公开(public)。
- 在项目根目录的README.md中写下:本题的问答、思考和思考题等仅需用文字阐述的题目的答案。
- 向仓库上传你的代码和运行截图等,并在README.md中展示你的运行截图,并作相应阐述。
- 向我们提交仓库的URL地址。提交点这里
祝你编码愉快,我们期待在提交列表中看到你的优秀答案!
出题人:
moriko
QQ:3580235054
