Java动态创建GeoJSON:从Jackson树模型到流式写入的完整实践

发布时间:2026/10/17 9:33:19
Java动态创建GeoJSON:从Jackson树模型到流式写入的完整实践
上周帮一个做物联网大屏的朋友排查了一个很经典的问题数据库里几百个传感器的坐标和状态数据每30秒更新一次前端地图要用GeoJSON实时渲染点位但他们还在用最原始的方式——后端拼一段JSON字符串返回。结果就是转义问题不断、属性字段一变就得改代码、数据量稍微上来一点响应就慢得没法看。我当时就跟他说你得让Java在运行的时候动态把这些数据“组装”成GeoJSON而不是手写死结构。今天这篇就把我自己做地图数据接口这些年关于动态创建GeoJSON的核心思路、完整代码、坑点规避一次性讲透照着做就能直接用。这篇文章适合这几类人后端Java开发要接前端地图可视化、做GIS数据服务接口、或者面对的是IoT/设备轨迹这类需要实时更新地理位置数据的场景。我不打算从GeoJSON标准文档抄概念而是站在“真实项目里到底怎么写”的角度把它掰开揉碎讲清楚。1. 先理解GeoJSON的数据模型1.1 为什么动态创建的主力是FeatureCollectionGeoJSON本质上就是一个有固定“语法规则”的JSON结构它把地理要素分成了点、线、面这些几何类型又用Feature和FeatureCollection把几何和业务属性组合在一起。你不需要把整套规范背下来只需要记住一条绝大多数业务系统里前端地图要渲染的东西都是FeatureCollection。原因很简单。单独的Point或者LineString只能表达“一个东西长在哪”但实际业务里一个点位必然带着设备编号、状态、温度、上报时间这些属性。FeatureCollection就是用来打包这一堆“带属性的几何对象”的容器。它的基本骨架长这样{ type: FeatureCollection, features: [ { type: Feature, geometry: { type: Point, coordinates: [116.39, 39.9] }, properties: { id: S001, status: online } } ] }这里面的geometry描述位置和形状properties放业务字段两者解耦。前端不管你是Leaflet还是Mapbox拿到这个结构就知道怎么画图层、怎么弹窗展示属性。所以动态创建GeoJSON本质上就是动态往features数组里塞数据。1.2 “动态”到底动在哪里很多刚接触的人会把“动态创建”理解为“在程序里拼JSON字符串”这是完全跑偏的。动态创建的精髓是GeoJSON的骨架是固定的标准但里面的坐标值、属性字段、要素数量全部来自运行时数据。我举一个非常典型的场景你自己对号入座。某环境监测平台数据库里有一张传感器表字段包括id、name、lng、lat、status、temperature、report_time。前端大屏每10秒请求一次接口要看到所有传感器的最新位置和状态。这个时候坐标值在变因为设备可能移动属性值在变温度和状态实时刷新要素数量在变因为可能随时有新设备接入如果每次数据变化都人工改一个GeoJSON文件或者写死在代码里那你一天啥也不用干了。动态创建解决的就是这件事——后端接收请求查数据库把查询结果实时构造成标准的GeoJSON返回给前端。数据结构是死的数据内容是活的这就是核心边界。2. 动态创建GeoJSON的路线选型2.1 三条路线横向对比我在不同项目里见过三种做法逐一给你拆一下。这步选型如果错了后面会非常痛苦。方案上手难度依赖重量动态灵活性大规模数据性能适用场景手写字符串拼接低无极差差临时调试、一次性测试通用JSON库树模型中轻强中绝大多数业务接口专用GIS框架高重中中上复杂空间运算、坐标转换先说最不推荐的手写字符串拼接。我知道很多同学图省事直接String.format({\type\:\Feature\,\coordinates\:[%s,%s]}, lng, lat)这样写。这个方案在数据量小、字段固定、没有特殊字符的时候看起来很快但一旦某个属性值是用户输入里面带了个引号或者换行符整个JSON结构直接崩掉。更不用说字段一多拼接顺序一乱找bug能找一晚上。我的态度非常明确这不是一个可选的工程方案最多只能算临时应急的玩具代码。再说重型方案。GeoTools这类GIS框架确实功能强大能做坐标投影转换、空间关系计算、复杂几何操作但代价是引入一大坨依赖API学习曲线陡峭。如果只是要把数据库里的经纬度变成GeoJSON返回给前端这属于用牛刀杀鸡。我一般只在需要做空间运算比如判断点是否在面内、做缓冲区分析时才考虑它。2.2 为什么我推荐“Map结构 通用JSON库”真正适合大多数业务场景的是通用JSON库的树模型方案。以Jackson为例它提供了ObjectNode和ArrayNode可以让你像操作Map一样动态构建JSON结构字段想加就加不想加就不写。这个方案最大的优势在于它彻底解决了动态字段的问题。我前面说过properties里的业务属性是多变的。如果用POJO类去固定接收那数据库加一个字段你就要改类、重新编译但用Map或者ObjectNode你可以在运行时遍历数据源的字段集合一股脑塞进去代码一行都不用改。我通常的做法是数据库查询结果用ListMapString, Object接收然后用Jackson的树模型构造FeatureCollection。这样数据表加字段、减字段后端逻辑完全不受影响。如果你有特殊需求想精简返回字段也可以在构造时过滤。下面第三部分就是完整的实操代码。3. 基于Jackson的动态创建实操3.1 最小可用一条SQL变成FeatureCollection先搭建依赖。如果你用的是Spring BootJackson已经内置了不用额外引入如果是纯Java项目加这一条就行dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency接下来是核心实现。假设你已经通过MyBatis或JdbcTemplate查到了传感器列表ListMapString, Object每一行就是一条记录。下面这段代码就是整个动态创建的骨架import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; public class GeoJsonBuilder { private final ObjectMapper mapper new ObjectMapper(); public String buildFeatureCollection(ListMapString, Object rows) { ObjectNode root mapper.createObjectNode(); root.put(type, FeatureCollection); ArrayNode features root.putArray(features); for (MapString, Object row : rows) { ObjectNode feature features.addObject(); feature.put(type, Feature); // 构造geometry ObjectNode geometry feature.putObject(geometry); geometry.put(type, Point); ArrayNode coords geometry.putArray(coordinates); coords.add(((Number) row.get(lng)).doubleValue()); coords.add(((Number) row.get(lat)).doubleValue()); // 构造properties剔除经纬度字段 ObjectNode properties feature.putObject(properties); for (Map.EntryString, Object entry : row.entrySet()) { String key entry.getKey(); if (lng.equals(key) || lat.equals(key)) { continue; } properties.putPOJO(key, entry.getValue()); } } return mapper.writeValueAsString(root); } }这一段代码有四个关键点你需要注意第一mapper.createObjectNode()创建的是根节点putArray(features)会在根节点下创建一个数组后续每个feature都是往这个数组里addObject。这是Jackson树模型动态构建的典型套路。第二geometry的coordinates坐标组一定要先放经度再放纬度。row.get(lng)取出来是数据库中存的经度字段row.get(lat)是纬度字段add的顺序不能搞反否则所有点位都会跑到海里。这件事我在第五部分还会展开说。第三properties里我用了putPOJO。这个方法会自动把各种类型的值转换成对应的JSON节点——数字变成数字、字符串变成字符串、Date对象变成时间戳或格式化字符串。它比手动判断类型再put要省事得多。第四我给features这个ArrayNode起名叫features时有人会担心前端解析不了。不会Jackson写入的时候字段名就是字符串输出效果和手写的JSON完全一致。3.2 properties的任意字段怎么塞上面代码里我写了一个for循环遍历row的所有字段把除了经纬度以外的所有字段都塞进properties。这就是处理动态字段的核心思路不预设字段白名单除了明确排除的坐标字段剩下的全部透传。有人可能会问为什么不直接排除坐标字段后把整个Map塞进properties如果你用的是ObjectNode.setAll(row)这种API它会按照Map的key生成字段确实也能做。但要注意两点Map里如果含有null值Jackson会输出field: null。前端拿到null属性如果做弹窗渲染时不判空页面会直接报错。Map里的key是字符串如果你不想让某些内部字段暴露给前端比如数据库主键、内部状态码用for循环逐个put的时候可以顺手做过滤。所以在实际生产里我推荐循环塞值而不是直接把整个Map塞进去。过滤逻辑还可以玩得更花比如把数据库里的字段名report_time改成前端友好的reportTime再put或者把某些字段做单位换算后输出。这些都是循环里的几行代码的事。3.3 序列化配置控制输出而不是被输出控制Jackson默认的序列化行为在生成GeoJSON时有两个地方不太顺手需要配置一下。第一是null值。前面说了Map里的null字段会原样输出field: null这在GeoJSON里不是非法但对前端不友好。我一般在构造ObjectMapper时加上全局配置mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);这样所有null字段自动不输出前端拿到的properties更干净。第二是日期格式。如果你在for循环里直接properties.putPOJO(reportTime, date)Jackson默认会把Date序列化成时间戳数字比如1700000000000。虽然前端也能解析但如果你的前方约定要ISO字符串可以这样配置mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.registerModule(new JavaTimeModule());注意JavaTimeModule需要额外引入jackson-datatype-jsr310依赖。Spring Boot环境下这两个配置也可以通过配置文件或者自定义Jackson2ObjectMapperBuilderCustomizer设置原理相同。4. 各种Geometry的结构与构造4.1 点线面坐标数组的层级规律业务需求不只是画点。设备轨迹要画线区域告警要画面这些东西动态创建的时候结构完全不同。GeoJSON各种Geometry类型的坐标层级其实有规律点是最小单元线是多点集合面是多线集合。我用一个表把常用的几个类型给你列清楚类型coordinates值结构一个坐标单元的形态Point[lng, lat]两个数字MultiPoint[[lng,lat], [lng,lat]]多个点LineString[[lng,lat], [lng,lat]]至少两个点连成线MultiLineString[[[lng,lat],...], [[lng,lat],...]]多条线Polygon[[[lng,lat],...], [[lng,lat],...]]多个环第一个是外环MultiPolygon[[[[lng,lat],...],...], ...]多个面看出来没每个类型就是在前一个类型外圈多套一层数组。MultiPoint就是Point的数组LineString是Point的数组但含义是连成线MultiLineString又是LineString的数组。用Jackson构造的时候对应多套一层ArrayNode就行。举例构造一个LineString的geometry核心代码是这样ObjectNode geometry feature.putObject(geometry); geometry.put(type, LineString); ArrayNode coords geometry.putArray(coordinates); for (double[] point : trackPoints) { ArrayNode coord coords.addArray(); coord.add(point[0]); coord.add(point[1]); }4.2 Polygon的闭合与洞Polygon是坑最多的地方。它的coordinates是一个“环”的数组每个环由至少4个坐标点组成而且第一个点和最后一个点必须相同这就是环的闭合要求。什么叫闭合如果数据库里存的区域边界是[[116.4,39.9],[116.5,39.9],[116.5,40.0],[116.4,39.9]]头尾一样这就是闭合环。但很多业务系统的数据并不规范存的时候最后那个点丢了只有三个点。前端渲染出来区域就是一个开口的图形填充色会一片混乱。所以在动态创建之前一定要做一次闭合校验缺了就把第一个点补到末尾Listdouble[] ring ...; if (ring.get(0)[0] ! ring.get(ring.size() - 1)[0] || ring.get(0)[1] ! ring.get(ring.size() - 1)[1]) { ring.add(ring.get(0)); }至于洞其实就是在外环后面继续追加环数组。第一个环是外边界后面的环都是挖掉的空洞区域。比如一个带湖的岛屿Polygon就应该是外环加一个湖的环。构造代码上跟外环没有区别只是数组的位置决定了语义。4.3 GeometryCollection的嵌套使用有一种比较少用但你必须知道的类型叫GeometryCollection。它不再是直接给coordinates数组而是嵌套包含一个geometries数组里面可以混合放Point、LineString、Polygon任意类型。典型场景是一个地块同时需要边界面和几个取水点点打成一组返回。构造的时候略微不同ObjectNode geometry feature.putObject(geometry); geometry.put(type, GeometryCollection); ArrayNode geometries geometry.putArray(geometries); ObjectNode pointGeo geometries.addObject(); pointGeo.put(type, Point); pointGeo.putArray(coordinates).add(116.39).add(39.9); ObjectNode polygonGeo geometries.addObject(); polygonGeo.put(type, Polygon); ArrayNode polygonCoords polygonGeo.putArray(coordinates); // 继续往里塞环...不过说实话我实际项目中用到GeometryCollection的场景非常少。GeoJSON标准也建议尽量拆成多个Feature而不是一个复杂Geometry因为前端做样式和事件绑定都是按Feature维度来的。这个地方知道有这种类型、能看懂别人代码就够了不建议你在业务里主动用它。5. 必须处理的坐标方向与精度问题5.1 经纬度顺序史上最高频的坑Go语言社区有个经典段子就是“千辛万苦把GeoJSON调通了结果所有点画到了海里”。绝大多数原因就是经纬度顺序搞反了。GeoJSON标准白纸黑字写着coordinates里面第一项是经度longitude第二项是纬度latitude。但是我们日常口语都说“经纬度”很多数据库表设计字段名却是lat在前、lng在后甚至有人习惯存成“纬度,经度”的字符串。一旦代码里直接按数据库字段顺序取值比如先row.get(lat)再row.get(lng)做出来的GeoJSON在真正的地图上就会飞到奇怪的位置。我给自己定了一条死规矩所有构造coordinates的代码都写一个显式工具方法防止脑子一热写反public static void addCoord(ArrayNode coords, double lng, double lat) { coords.add(lng); coords.add(lat); }方法签名上就强制第一个参数是经度用这个方法的地方只要参数不传反就不会出问题。另外还可以做一次合法性校验经度范围必须在-180到180纬度范围必须在-90到90。如果出现反直觉的值比如纬度是116那不用想百分百是顺序错了public static void validateCoord(double lng, double lat) { if (lng -180 || lng 180) { throw new IllegalArgumentException(经度越界: lng); } if (lat -90 || lat 90) { throw new IllegalArgumentException(纬度越界: lat); } }5.2 RFC 7946环形方向规则与修正算法这是做面数据时最隐蔽的坑。GeoJSON规范RFC 7946明确规定一个多边形的外环必须是逆时针方向内环洞必须是顺时针方向。注意这和很多人的直觉相反也和旧版GeoJSON规范、以及某些前端地图库的早期实现不一样。为什么会有这个规则它本质上是为了在同一个坐标系里区分“边界内部”和“边界外部”——外环逆时针时其左侧就是内部区域。如果你给的地图渲染库生成了一个方向反了的外环它会把整个区域“翻过来”填充色不是在大陆上而是在海洋里或者干脆出现带状渲染错乱。问题是业务数据库里存的面数据方向五花八门。你不知道上游的坐标系和当时录入时的约定最稳妥的做法是在动态构建时统一修正方向。修正算法核心是“鞋带公式”计算有向面积面积正负代表方向private static double signedArea(Listdouble[] ring) { if (ring.size() 4) { return 0; } double area 0; for (int i 0; i ring.size() - 1; i) { double[] p1 ring.get(i); double[] p2 ring.get(i 1); area (p2[0] - p1[0]) * (p2[1] p1[1]); } return area / 2; } public static void normalizeRings(ListListdouble[] rings) { if (rings null || rings.isEmpty()) { return; } Listdouble[] outer rings.get(0); if (outer.size() 4 signedArea(outer) 0) { Collections.reverse(outer); } for (int i 1; i rings.size(); i) { Listdouble[] hole rings.get(i); if (hole.size() 4 signedArea(hole) 0) { Collections.reverse(hole); } } }这里的逻辑是外环如果面积计算为负说明是顺时针就反转成逆时针内环则相反。反转操作是Collections.reverse(ring)把环里的坐标点顺序颠倒一下几何形状保持不变只是遍历方向变了。这段代码建议直接放进你的GIS工具类里因为只要做面数据没有一次用不到。5.3 坐标精度控制数据库存的经纬度可能是双精度浮点数经度可能长达15位小数。这些数据直接塞进GeoJSON会让JSON体积膨胀而且输出一堆毫无意义的尾数前端还要花精力解析。地图显示的精度在6位小数时大约是0.1米绝大多数业务系统根本不需要这么细。所以动态创建时控制一下输出精度既减小数据体积也能让数据看起来干净。首选方案是构造时用BigDecimal缩放private static double roundTo6(double value) { return BigDecimal.valueOf(value) .setScale(6, RoundingMode.HALF_UP) .doubleValue(); } coords.add(roundTo6(lng)); coords.add(roundTo6(lat));有一点要提醒不要过度截断。如果你的点位精度要求精确到厘米级那保留7到8位小数才够。而且有些传感器上报的坐标本身就带噪声截断到6位相当于做了一个软平滑反而有好处。但如果你处理的是测绘级数据截断到6位可能造成可感知的偏移这种场景就保持原始精度别一刀切。6. 大数据量下的性能优化6.1 流式写入告别一次性大字符串动态创建GeoJSON最常见的问题是什么数据量一大几百上千个Feature拼完再一起序列化内存和响应时间双双爆炸。我见过一个项目几千个点位一次性构造出一个超大字符串接口ResponseTime直接飙到5秒以上最后发现是JSON序列化阶段把所有数据都加载到了内存里。解决办法是用Jackson的流式APIJsonGenerator来写。它不构建完整的树模型而是边遍历数据边写输出流内存占用可以压缩一个数量级。下面这段示例展示了如何把前面树模型的构建逻辑改成流式写法public void writeGeoJson(ListMapString, Object rows, OutputStream out) throws IOException { try (JsonGenerator gen mapper.getFactory().createGenerator(out)) { gen.writeStartObject(); gen.writeStringField(type, FeatureCollection); gen.writeArrayFieldStart(features); for (MapString, Object row : rows) { gen.writeStartObject(); gen.writeStringField(type, Feature); gen.writeObjectFieldStart(geometry); gen.writeStringField(type, Point); gen.writeArrayFieldStart(coordinates); gen.writeNumber(((Number) row.get(lng)).doubleValue()); gen.writeNumber(((Number) row.get(lat)).doubleValue()); gen.writeEndArray(); gen.writeEndObject(); gen.writeObjectFieldStart(properties); for (Map.EntryString, Object entry : row.entrySet()) { String key entry.getKey(); if (lng.equals(key) || lat.equals(key) || entry.getValue() null) { continue; } gen.writeObjectField(key, entry.getValue()); } gen.writeEndObject(); gen.writeEndObject(); } gen.writeEndArray(); gen.writeEndObject(); } }这个写法看起来比树模型啰嗦但对内存是真的友好。在数据量超过500个Feature时我建议直接上流式。6.2 和数据库查询的有效衔接动态创建的瓶颈不在构造JSON而在取数。查询传感器表时一定要只查GeoJSON需要的字段。我之前见过有人把整行数据包括大字段、二进制列都查出来就为了取两个坐标纯粹浪费。SQL写成这样才是正路SELECT id, name, lng, lat, status, temperature, report_time FROM t_sensor WHERE status online另外一个容易被忽略的点是游标。如果你连续查询上万条记录一次性加载到内存那流式写入也救不了你。数据量大时考虑分批查询或者用游标方式逐步读取查一批写一批把“全量数据先塞内存再转JSON”这个坏习惯改掉。6.3 顺手算一个bbox给前端减负FeatureCollection的顶层支持一个可选的bbox字段表示整个数据集的包围盒格式是[minLng, minLat, maxLng, maxLat]。前端拿到这个值可以快速把地图视角调整到数据范围不用自己遍历所有坐标去算。这个字段对后端来说几乎零成本因为在遍历数据库结果构造features时顺手就可以统计出四个极限值double minLng Double.MAX_VALUE, minLat Double.MAX_VALUE; double maxLng -Double.MAX_VALUE, maxLat -Double.MAX_VALUE; for (MapString, Object row : rows) { double lng ((Number) row.get(lng)).doubleValue(); double lat ((Number) row.get(lat)).doubleValue(); minLng Math.min(minLng, lng); minLat Math.min(minLat, lat); maxLng Math.max(maxLng, lng); maxLat Math.max(maxLat, lat); } ArrayNode bbox root.putArray(bbox); bbox.add(minLng); bbox.add(minLat); bbox.add(maxLng); bbox.add(maxLat);我实测过一个两千点的接口加了bbox以后前端首屏渲染定位地图的速度肉眼可见地变快了。这是个很划算的优化。7. 常见问题与避坑速查表7.1 高频问题速查表把我在实际项目中碰到过的典型问题整理成了一张表你排查的时候可以先对着症状找原因现象根本原因解决方案前端地图点位全部跑到海里或非洲经纬度顺序写反统一改成[经度, 纬度]多边形填充区域反了外环是顺时针用有向面积修正环方向区域边界是断开的线而非闭合面环没有闭合判定首尾点不等则补闭合properties字段为null导致前端弹窗报错序列化时没过滤null配置NON_NULL或在构造时判空中文乱码HTTP响应没带charset设置Content-Type为application/json;charsetUTF-8属性值含引号/换行符导致JSON崩字符串拼接导致结构破坏改用Jackson树模型或流式API几千个点响应极慢全量构建大字符串再序列化改流式写入JsonGenerator接口返回的日期是数字时间戳Jackson默认序列化Date为时间戳注册JavaTimeModule并关闭时间戳输出7.2 排查前端解析不了的通用思路前端的同学跑过来跟你说“你接口挂了地图加载不出来”的时候不要慌按这个顺序排查。第一步把后端接口返回的字符串原样打印出来贴到任意在线JSON校验工具里。如果JSON本身语法错误那问题出在后端构造环节重点检查是不是字符串拼接没转义。第二步如果你确认JSON语法没问题那就看GeoJSON结构是否符合标准。最常见的是type字段拼写错了比如把Feature写成了feature前端解析器压根不认。第三步检查geometry里面的嵌套层级。Polygon的coordinates如果少了一层数组就会变成“环数组缺环”的情况。这需要用GeoJSON官方的验证工具去校验而不是肉眼观察。第四步检查接口Content-Type。很多前端框架比如Axios会根据响应头自动处理数据。如果你的后端返回的是application/json;charsetUTF-8那一般没事如果手滑设置成了text/plain并且前端没有转JSON也会出问题。7.3 一个调试利器推荐最后分享一个我压箱底的调试习惯把所有构造GeoJSON的代码留一个“调试模式”开关。开启时接口不直接返回给前端而是把生成的GeoJSON字符串写到一个临时文件或者日志里。为什么这么做因为直接在接口调试过程中看输出数据量大时根本看不全而且前端报错信息不足以定位到具体是哪个Feature出了问题。调试模式下就能用JSON工具把整个结构放大仔细看定位到具体是哪条数据、哪个Properties字段引发的异常。我自己就因为这个习惯好几次在十分钟内定位到了那种“一万个Feature里只有一个坐标写反”的诡异问题。个人经验收尾反复踩过几次坑之后我最大的体会是动态创建GeoJSON的难点从来不在“会用Jackson”而在于你有没有把GeoJSON的规范真正内化成工程习惯。经纬度顺序、环方向、闭合、属性透传这些细节任何一个疏忽产出的都是“看似正常但前端就是渲染不出来”的垃圾数据。我建议你把这篇文章里的工具方法——方向修正、坐标校验、闭合补全、精度控制——沉淀成你们团队自己的GIS工具类每次写新接口都调这同一套方法。稳定性上来了你才有精力去处理真正的业务逻辑而不是天天帮前端擦屁股。