1. 为什么 Java 直连 MongoDB 写起来这么别扭
如果你在 Java 项目里用过原生的 MongoDB Driver,大概率经历过这种场景:coll.find()拿到一个DBCursor,然后手动while(cursor.hasNext())遍历,再一个个getString("name")、getInteger("age")往实体里塞。字段一多,代码就变成流水账;换个集合,又得复制粘贴一遍。这跟当年用 JDBC 裸写ResultSet的痛苦一模一样。
commons-dbutils当年解决的就是这个问题——用BeanHandler、BeanListHandler把ResultSet自动映射成 JavaBean。MongoDB 这边其实也能照搬这套思路:定义一个ResultSetHandler<T>回调接口,让调用方决定返回单个实体还是 List,底层用反射 + 内省把DBObject的字段灌进实体。这样业务代码里只需要一行find(query, new BeanListHandler<>(Person.class), "person"),剩下的映射全自动。
这篇面向的是需要统一 MongoDB 连接配置、又不想引入重型 ORM 的 Java 开发者。我会给出一套可复制的DBUtils骨架:ResultSetHandler接口、BaseHandler内省基类、BeanHandler/BeanListHandler两个实现,再加一个从settings.json读取连接参数的配置层。最后用 TaoToken 的模型对话能力验证一下这套骨架跑出来的结果对不对。整套代码不依赖 Spring,纯 Java + 官方 Driver 就能跑。
2. 前置准备:连接配置与 TaoToken 接入
2.1 为什么连接参数要抽到 settings.json
硬编码new MongoClient("127.0.0.1:27017")在本地跑没问题,一旦要区分开发/测试环境就抓瞎。我的做法是把 MongoDB 的连接信息、库名、默认集合名统一放进settings.json,Java 侧用一个轻量配置类加载。这样换环境只改 JSON,不动代码。
{ "mongo": { "host": "127.0.0.1", "port": 27017, "database": "one", "defaultCollection": "person", "connectTimeoutMs": 3000, "socketTimeoutMs": 5000 }, "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的Key", "model": "claude-sonnet-4-20250514" } }taotoken这一段是给后面验证环节用的。TaoToken 提供统一的模型调用入口,兼容 Anthropic 风格的接口,Java 里用HttpClient直接 POST 就行,不需要额外 SDK。API Key 在控制台的 API Keys 页面生成,地址是https://taotoken.net/console/api-keys。
2.2 Maven 依赖
只需要官方 Driver 和一个 JSON 解析库。我用 Jackson 读settings.json,你也可以换成 Gson。
<dependencies> <dependency> <groupId>org.mongodb</groupId> <artifactId>mongo-java-driver</artifactId> <version>3.12.14</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies>注意这里用的是 3.x 的mongo-java-driver,因为DBObject、DBCursor这套 API 在 4.x 里已经被Document、FindIterable取代。如果你项目已经上了 4.x,思路完全一样,把类型换掉即可,反射映射那段逻辑不用动。
2.3 配置加载类
package com.zk.config; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.InputStream; public class SettingsLoader { private static JsonNode root; static { try (InputStream in = SettingsLoader.class .getClassLoader().getResourceAsStream("settings.json")) { root = new ObjectMapper().readTree(in); } catch (Exception e) { throw new RuntimeException("settings.json 加载失败", e); } } public static String mongoHost() { return root.path("mongo").path("host").asText("127.0.0.1"); } public static int mongoPort() { return root.path("mongo").path("port").asInt(27017); } public static String mongoDatabase() { return root.path("mongo").path("database").asText("one"); } public static String defaultCollection() { return root.path("mongo").path("defaultCollection").asText("person"); } }把settings.json放在src/main/resources下,getResourceAsStream就能直接读到。这样连接参数和代码彻底解耦。
3. 可复制配置:DBUtils 骨架完整代码
3.1 ResultSetHandler 回调接口
这是整套设计的核心。它把「怎么处理游标」这件事交给调用方,底层只负责把DBCursor递过去。
package com.zk.db.handler; import com.mongodb.DBCursor; public interface ResultSetHandler<T> { T handler(DBCursor cursor) throws Exception; }3.2 BaseHandler 内省基类
BaseHandler不直接实现接口,它只提供「把DBObject的字段灌进实体」这个公共能力。用Introspector拿到所有属性描述符,再用PropertyDescriptor.getWriteMethod()拿到 setter,反射调用。这里我加了一个_前缀的兼容分支,因为 MongoDB 里有些字段会带下划线,而 Java 实体是驼峰命名。
package com.zk.db.handler; import com.mongodb.DBObject; import java.beans.BeanInfo; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.Method; import java.util.Set; public class BaseHandler<T> { private final Class<T> clazz; public BaseHandler(Class<T> clazz) { this.clazz = clazz; } public void populate(T t, Set<String> keys, DBObject object) throws Exception { BeanInfo info = Introspector.getBeanInfo(clazz); PropertyDescriptor[] pds = info.getPropertyDescriptors(); for (PropertyDescriptor pd : pds) { String proName = pd.getName(); if ("class".equals(proName)) { continue; } Method write = pd.getWriteMethod(); if (write == null) { continue; } if (keys.contains(proName)) { write.invoke(t, object.get(proName)); } else if (keys.contains("_" + proName)) { write.invoke(t, object.get("_" + proName)); } } } }"class".equals(proName)这个判断必须加。Introspector会把getClass()也当成一个属性,如果不跳过,反射调用setClass时会直接抛异常。
3.3 BeanHandler 单实体映射
package com.zk.db.handler; import com.mongodb.DBCursor; import com.mongodb.DBObject; import java.util.Set; public class BeanHandler<T> extends BaseHandler<T> implements ResultSetHandler<T> { private final Class<T> clazz; public BeanHandler(Class<T> clazz) { super(clazz); this.clazz = clazz; } @Override public T handler(DBCursor cursor) throws Exception { if (cursor.hasNext()) { T t = clazz.getDeclaredConstructor().newInstance(); DBObject object = cursor.next(); Set<String> keys = object.keySet(); populate(t, keys, object); return t; } return null; } }3.4 BeanListHandler 集合映射
package com.zk.db.handler; import com.mongodb.DBCursor; import com.mongodb.DBObject; import java.util.ArrayList; import java.util.List; import java.util.Set; public class BeanListHandler<T> extends BaseHandler<T> implements ResultSetHandler<List<T>> { private final Class<T> clazz; public BeanListHandler(Class<T> clazz) { super(clazz); this.clazz = clazz; } @Override public List<T> handler(DBCursor cursor) throws Exception { List<T> list = new ArrayList<>(); while (cursor.hasNext()) { T t = clazz.getDeclaredConstructor().newInstance(); DBObject object = cursor.next(); Set<String> keys = object.keySet(); populate(t, keys, object); list.add(t); } return list; } }3.5 MongoDb 核心封装类
这个类把连接管理、查询器、回调处理串起来。注意find(DBObject, ResultSetHandler, String)这个方法——它就是整个 DBUtils 的门面,调用方传一个 handler 进来,底层负责把游标喂给它。
package com.zk.db; import com.mongodb.DB; import com.mongodb.DBCollection; import com.mongodb.DBCursor; import com.mongodb.DBObject; import com.mongodb.MongoClient; import com.zk.config.SettingsLoader; import com.zk.db.handler.ResultSetHandler; public class MongoDb { private static MongoClient client; private static DB db; public MongoDb() { this(SettingsLoader.mongoDatabase()); } public MongoDb(String dbName) { if (client == null) { client = new MongoClient( SettingsLoader.mongoHost(), SettingsLoader.mongoPort() ); } db = client.getDB(dbName); } public <T> T find(DBObject query, ResultSetHandler<T> rsh, String collName) { try { DBCursor cursor = find(query, null, collName); return rsh.handler(cursor); } catch (Exception e) { throw new RuntimeException("查询失败: " + collName, e); } } public DBCursor find(DBObject ref, DBObject keys, String collName) { DBCollection coll = db.getCollection(collName); return coll.find(ref, keys); } public DBCursor find(DBObject ref, DBObject keys, int start, int limit, String collName) { return find(ref, keys, collName).limit(limit).skip(start); } public DBCollection collection(String collName) { return db.getCollection(collName); } public void close() { if (client != null) { client.close(); client = null; } } }MongoClient做成静态单例,因为它是线程安全的,每次new一个会浪费连接池。close()留给应用关闭时调用。
3.6 实体类
package com.zk.bean; public class Person { private String id; private String name; private Integer age; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } @Override public String toString() { return "Person{id='" + id + "', name='" + name + "', age=" + age + "}"; } }字段类型要和 MongoDB 里存的一致。如果库里age存的是int,实体用Integer没问题;如果存的是String,反射调用setAge时会抛IllegalArgumentException,这个坑后面排障章节会讲。
4. 验证请求:连接测试与 CRUD 跑通
4.1 插入测试数据
先往person集合里塞几条数据,方便后面验证映射。
package com.zk; import com.mongodb.BasicDBObject; import com.mongodb.DBCollection; import com.mongodb.DBObject; import com.zk.db.MongoDb; public class SeedData { public static void main(String[] args) { MongoDb mongo = new MongoDb(); DBCollection coll = mongo.collection("person"); coll.drop(); for (int i = 1; i <= 5; i++) { DBObject doc = new BasicDBObject(); doc.put("name", "user_" + i); doc.put("age", 20 + i); coll.insert(doc); } System.out.println("插入完成,当前文档数: " + coll.count()); mongo.close(); } }运行后控制台输出插入完成,当前文档数: 5。
4.2 用 BeanListHandler 查询集合
package com.zk; import com.mongodb.BasicDBObject; import com.mongodb.DBObject; import com.zk.bean.Person; import com.zk.db.MongoDb; import com.zk.db.handler.BeanListHandler; import java.util.List; public class QueryDemo { public static void main(String[] args) { MongoDb mongo = new MongoDb(); DBObject query = new BasicDBObject("age", new BasicDBObject("$gte", 22)); List<Person> list = mongo.find( query, new BeanListHandler<>(Person.class), "person" ); for (Person p : list) { System.out.println(p); } mongo.close(); } }预期输出:
Person{id='null', name='user_2', age=22} Person{id='null', name='user_3', age=23} Person{id='null', name='user_4', age=24} Person{id='null', name='user_5', age=25}id是 null,因为插入时没手动设_id,MongoDB 自动生成的_id是ObjectId类型,而实体里id是String,类型不匹配所以没灌进去。要拿到 id,把实体字段改成ObjectId类型,或者在populate里加类型转换。这是设计取舍,不是 bug。
4.3 用 BeanHandler 查单条
DBObject query = new BasicDBObject("name", "user_3"); Person p = mongo.find(query, new BeanHandler<>(Person.class), "person"); System.out.println(p);输出Person{id='null', name='user_3', age=23}。
4.4 用 TaoToken 验证映射结果
查出来的数据对不对,除了肉眼看控制台,还可以让模型帮你核对字段映射是否符合预期。TaoToken 的模型对话接口兼容 Anthropic 风格,Java 里用HttpClient直接调。
package com.zk; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class TaoTokenVerify { public static void main(String[] args) throws Exception { ObjectMapper mapper = new ObjectMapper(); ObjectNode body = mapper.createObjectNode(); body.put("model", "claude-sonnet-4-20250514"); body.put("max_tokens", 512); ArrayNode messages = body.putArray("messages"); ObjectNode msg = messages.addObject(); msg.put("role", "user"); msg.put("content", "我有一段 MongoDB 查询结果映射到 Java 实体的输出:" + "Person{id='null', name='user_3', age=23}。" + "请判断 name 和 age 字段是否映射正确,id 为 null 可能是什么原因?"); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://taotoken.net/api/v1/messages")) .header("Content-Type", "application/json") .header("x-api-key", "sk-你的Key") .header("anthropic-version", "2023-06-01") .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body))) .build(); HttpResponse<String> resp = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); JsonNode result = mapper.readTree(resp.body()); System.out.println(result.path("content").get(0).path("text").asText()); } }模型会告诉你name、age映射正确,id为 null 是因为_id是ObjectId而实体字段是String,类型不匹配导致 setter 没被调用。这种验证方式比翻文档快,尤其适合字段多、类型杂的场景。如果你更习惯在网页里直接对话,可以打开模型对话页面粘贴同样的内容。
5. 本篇常见错排查
5.1 反射调用 setter 抛 IllegalArgumentException
最常见的原因是类型不匹配。MongoDB 里age存的是Integer,实体里写成了String,write.invoke(t, object.get("age"))就会炸。解决办法有两个:要么统一实体字段类型和库里的 BSON 类型,要么在populate里加一层类型转换。
Object value = object.get(proName); if (value != null && !pd.getPropertyType().isAssignableFrom(value.getClass())) { value = convert(value, pd.getPropertyType()); } write.invoke(t, value);convert方法按目标类型做Integer.parseInt、String.valueOf之类的转换。字段少的时候手动改实体更快,字段多就上转换层。
5.2 Introspector 把 class 当成属性
前面代码里"class".equals(proName)那个判断如果漏了,pd.getWriteMethod()会返回 null(因为Class没有setClass),然后write.invoke直接 NPE。加上判断就没事。
5.3 DBCursor 没关闭导致连接泄漏
find方法返回DBCursor后,如果调用方不遍历完也不关闭,游标会一直占着连接。我的做法是在ResultSetHandler.handler里遍历完就自动耗尽游标,但如果你手动拿DBCursor做分页,记得在 finally 里cursor.close()。
DBCursor cursor = mongo.find(query, null, "person"); try { while (cursor.hasNext()) { // 处理 } } finally { cursor.close(); }5.4 settings.json 读不到
getResourceAsStream("settings.json")返回 null,通常是文件没放在src/main/resources根目录,或者 Maven 没把它打进 classpath。检查target/classes下有没有这个文件。IDEA 里如果改了 resources 目录没 rebuild,也会读不到。
5.5 TaoToken 调用返回 401
x-api-key头没带对,或者 Key 已经失效。去 API Keys 页面重新生成一个,确认请求头名字是x-api-key而不是Authorization。Anthropic 风格接口用的是x-api-key,这点和 OpenAI 风格不一样。
5.6 分页 skip 参数理解偏差
find(ref, keys, start, limit, collName)里start是跳过的条数,不是页码。第 2 页每页 10 条,start应该是 10 而不是 2。这个参数命名容易误导,我在方法注释里写清楚了。
6. 接入文档与后续扩展
这套骨架的核心价值在于把「查询」和「映射」解耦。ResultSetHandler接口让你可以自由扩展——想要返回Map就写个MapHandler,想要返回 JSON 字符串就写个JsonHandler,底层MongoDb.find完全不用改。这就是面向接口编程的好处。
如果你要把这套代码用到实际项目里,下一步可以做的扩展:在MongoDb里加insert、update、delete的封装,让 DBUtils 覆盖完整 CRUD;把MongoClient换成连接池配置,从settings.json读连接数参数;给BaseHandler加注解支持,用@Field("_id")显式指定字段映射关系,比_前缀匹配更灵活。
TaoToken 的接入文档里有完整的接口说明和参数列表,Java、Python、Node 的调用示例都有。如果你在跑这套骨架时遇到映射报错,可以把异常栈和实体定义贴到模型对话里让模型帮你定位,比搜索引擎翻帖子快得多。长期做 Java 后端开发的话,Coding Plan 那边有包月方案,适合频繁调试的场景。