目录
-
- 一、背景与目标
- 二、环境版本说明
- 三、数据库设计
- 四、项目结构
- 五、核心实现
-
- [5.1 Entity 设计](#5.1 Entity 设计)
- [5.2 GeometryTypeHandler 终极实现](#5.2 GeometryTypeHandler 终极实现)
- [5.3 Mapper 层](#5.3 Mapper 层)
- [5.4 Service 层(业务逻辑收敛)](#5.4 Service 层(业务逻辑收敛))
- [5.5 Controller 层(HTTP 边界)](#5.5 Controller 层(HTTP 边界))
- 六、接口测试示例
-
- [6.1 新增 POI](#6.1 新增 POI)
- [6.2 更新 POI](#6.2 更新 POI)
- [6.3 删除 POI](#6.3 删除 POI)
- [6.4 查询详情](#6.4 查询详情)
- 七、踩坑复盘(重点)
-
- [7.1 MyBatis / MyBatis-Plus 自动配置冲突](#7.1 MyBatis / MyBatis-Plus 自动配置冲突)
- [7.2 Maven Compiler Plugin 版本问题](#7.2 Maven Compiler Plugin 版本问题)
- [7.3 PostGIS JDBC 返回格式陷阱](#7.3 PostGIS JDBC 返回格式陷阱)
- [7.4 EWKB SRID Flag 解析](#7.4 EWKB SRID Flag 解析)
- [7.5 TypeHandler 生效条件](#7.5 TypeHandler 生效条件)
- 八、最佳实践总结
技术栈:Spring Boot 3.5.5 + MyBatis-Plus 3.5.7 + PostgreSQL / PostGIS + JTS
本文验证 MyBatis-Plus 操作 PostGIS Geometry 字段的完整增删改查流程,并提供 TypeHandler 终极方案与踩坑复盘。
一、背景与目标
在 GIS 相关项目中,空间数据的持久化一直是一个核心需求。PostGIS 作为 PostgreSQL 的空间扩展,提供了强大的空间数据存储和查询能力。而 MyBatis-Plus 作为国内最流行的 ORM 框架之一,如何优雅地与之集成,是本文要解决的核心问题。
本文的目标非常简单:
- 验证 MyBatis-Plus 能否像操作普通字段一样,操作 PostGIS 的 Geometry 字段。
- 具体包括:新增 POI(兴趣点)、更新 POI、删除 POI、按 ID 查询详情。
二、环境版本说明
以下版本经过实际验证,可以正常工作:
| 组件 | 版本 |
|---|---|
| Spring Boot | 3.5.5 |
| MyBatis-Plus | 3.5.7 |
| MyBatis-Spring | 3.0.3(手动指定) |
| PostgreSQL / PostGIS | 18.4 / 3.6 |
| JTS (JTS Topology Suite) | 1.19.0 |
| Java | 17 |
| PostgreSQL JDBC Driver | 42.7.3 |
| PostGIS JDBC | 2024.1.0 |
⚠️ 关键提示:MyBatis-Plus 3.5.7 自带的 mybatis-spring 是 2.x 版本(适配 Spring Boot 2.x),与 Spring Boot 3.x 不兼容。必须排除低版本并手动引入 mybatis-spring 3.0.3。
三、数据库设计
创建 POI 表:
sql
CREATE TABLE poi (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
category VARCHAR(50),
geom geometry(Point, 4326)
);

字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | SERIAL | 主键,自增 |
| name | VARCHAR | POI 名称 |
| category | VARCHAR | 分类(景点 / 美食 / 酒店等) |
| geom | geometry(Point,4326) | 空间字段,存储经纬度(WGS84) |
SRID 4326 表示 WGS84 坐标系(经度 / 纬度)
四、项目结构
项目采用标准的 Maven 多包结构:
src/main/java/org/pgd/postgisdemo/
├── PostgisDemoApplication.java # 启动类
├── config/
│ └── MyBatisPlusConfig.java # MyBatis-Plus 配置(分页插件等)
├── controller/
│ └── PoiController.java # HTTP 接口层
├── service/
│ ├── PoiService.java # 服务接口
│ └── impl/
│ └── PoiServiceImpl.java # 服务实现
├── mapper/
│ └── PoiMapper.java # MyBatis Mapper 接口
├── entity/
│ └── PoiEntity.java # 数据库实体
├── dto/
│ ├── AddPoiReq.java # 新增请求 DTO
│ ├── UpdatePoiReq.java # 更新请求 DTO
│ └── PoiDetailResp.java # 详情响应 DTO
├── handler/
│ └── GeometryTypeHandler.java # Geometry <-> EWKB 转换器
└── common/
└── R.java # 统一响应封装
src/main/resources/
├── application.yml # Spring Boot 配置
└── mapper/
└── PoiMapper.xml # SQL 映射文件
pom.xml文件
java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.5</version>
<relativePath/>
</parent>
<groupId>org.pgd</groupId>
<artifactId>postgis-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>postgis-demo</name>
<description>postgis-demo</description>
<packaging>jar</packaging>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<mybatis-spring.version>3.0.3</mybatis-spring.version>
<postgresql.version>42.7.3</postgresql.version>
<postgis.version>2024.1.0</postgis.version>
<jts.version>1.19.0</jts.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
<exclusions>
<!-- 排除低版本mybatis-spring -->
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 明确指定适配Spring Boot 3.x的mybatis-spring版本 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>${mybatis-spring.version}</version>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<!-- PostGIS JDBC -->
<dependency>
<groupId>net.postgis</groupId>
<artifactId>postgis-jdbc</artifactId>
<version>${postgis.version}</version>
</dependency>
<!-- JTS -->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>${jts.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml文件
java
spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgis_36_sample # 改成你自己的数据库
username: postgres
password: 123456 # 改成你自己的密码
driver-class-name: org.postgresql.Driver
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: org.pgd.postgisdemo.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
五、核心实现
5.1 Entity 设计
Entity 是数据库表的直接映射,Geometry 字段使用 JTS 的 Geometry 类型,配合 @TableField(typeHandler = ...) 指定自定义类型处理器:
java
@Data
@TableName(value = "poi", autoResultMap = true)
public class PoiEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String category;
@TableField(typeHandler = GeometryTypeHandler.class)
private Geometry geom;
public PoiEntity() {}
public PoiEntity(String name, String category, Geometry geom) {
this.name = name;
this.category = category;
this.geom = geom;
}
}
关键点 :@TableName(autoResultMap = true) 是让 MyBatis-Plus 自动生成 resultMap 并应用 @TableField 上声明的 TypeHandler 的开关。没有它,Geometry 字段会始终为 null。
5.2 GeometryTypeHandler 终极实现
这是整个项目的核心。PostGIS JDBC 驱动默认以十六进制字符串形式返回 geometry 列(EWKB 编码),Handler 需要同时支持 EWKB 和 WKT 两种格式:
java
@MappedTypes(Geometry.class)
public class GeometryTypeHandler extends BaseTypeHandler<Geometry> {
private static final WKBReader WKB_READER = new WKBReader();
private static final WKTReader WKT_READER = new WKTReader();
private static final WKBWriter WKB_WRITER =
new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN, true);
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
Geometry geometry, JdbcType jdbcType)
throws SQLException {
byte[] wkb = WKB_WRITER.write(geometry);
ps.setBytes(i, wkb);
}
@Override
public Geometry getNullableResult(ResultSet rs, String columnName)
throws SQLException {
return parse(rs.getString(columnName));
}
@Override
public Geometry getNullableResult(ResultSet rs, int columnIndex)
throws SQLException {
return parse(rs.getString(columnIndex));
}
@Override
public Geometry getNullableResult(CallableStatement cs, int columnIndex)
throws SQLException {
return parse(cs.getString(columnIndex));
}
/**
* 核心解析:支持两种格式
* 1. WKT 文本(如 POINT(116.397 39.903))
* 2. EWKB 十六进制字符串(如 0101000020E6100000...)
*/
private Geometry parse(String value) throws SQLException {
if (value == null || value.isEmpty()) {
return null;
}
try {
// 尝试 WKT 解析(以数字或 POINT/LINESTRING/POLYGON 开头)
if (value.startsWith("POINT") || value.startsWith("LINESTRING")
|| value.startsWith("POLYGON") || value.startsWith("MULTI")) {
return WKT_READER.read(value);
}
// 尝试 EWKB 十六进制解析
byte[] bytes = hexToBytes(value);
return parseEwkb(bytes);
} catch (ParseException e) {
throw new SQLException("Failed to parse geometry: " + value, e);
}
}
private Geometry parseEwkb(byte[] bytes) throws ParseException {
if (bytes == null || bytes.length < 5) {
return null;
}
ByteBuffer buffer = ByteBuffer.wrap(bytes);
// Byte 0: byte order
byte bo = buffer.get();
buffer.order(bo == 0
? ByteOrder.BIG_ENDIAN
: ByteOrder.LITTLE_ENDIAN);
// Bytes 1-4: EWKB type word
int typeWord = buffer.getInt();
// Extract SRID if present
int srid = 0;
if ((typeWord & 0x20000000) != 0) {
srid = buffer.getInt();
}
// Remaining: pure coordinate data (no type word)
byte[] coordsOnly = new byte[buffer.remaining()];
buffer.get(coordsOnly);
// Reconstruct standard WKB:
// WKB = byte_order(1) + type_word(4) + coordinates
// The type word without the hasSRID flag:
int wkbTypeWord = typeWord & 0xDFFFFFFF; // clear hasSRID bit
// Build standard WKB bytes:
// [byte_order][type_word(4)][coordinates...]
byte[] standardWkb = new byte[1 + 4 + coordsOnly.length];
standardWkb[0] = bo; // byte order
// Write type word in correct byte order
if (bo == 0) {
// Big endian
standardWkb[1] = (byte) ((wkbTypeWord >> 24) & 0xFF);
standardWkb[2] = (byte) ((wkbTypeWord >> 16) & 0xFF);
standardWkb[3] = (byte) ((wkbTypeWord >> 8) & 0xFF);
standardWkb[4] = (byte) (wkbTypeWord & 0xFF);
} else {
// Little endian
standardWkb[1] = (byte) (wkbTypeWord & 0xFF);
standardWkb[2] = (byte) ((wkbTypeWord >> 8) & 0xFF);
standardWkb[3] = (byte) ((wkbTypeWord >> 16) & 0xFF);
standardWkb[4] = (byte) ((wkbTypeWord >> 24) & 0xFF);
}
System.arraycopy(coordsOnly, 0, standardWkb, 5, coordsOnly.length);
Geometry geometry = WKB_READER.read(standardWkb);
if (srid != 0) {
geometry.setSRID(srid);
}
return geometry;
}
/**
* 十六进制字符串 → 字节数组
*/
private static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] bytes = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
int high = Character.digit(hex.charAt(i), 16);
int low = Character.digit(hex.charAt(i + 1), 16);
if (high == -1 || low == -1) {
throw new IllegalArgumentException(
"Invalid hex string at position " + i + ": " + hex);
}
bytes[i / 2] = (byte) ((high << 4) | low);
}
return bytes;
}
}
写入逻辑 :使用 JTS 的 WKBWriter,维度设为 2(X, Y),字节序使用 LITTLE_ENDIAN(PostGIS 默认),includeSRID 设为 true(写入 EWKB 格式,保留 SRID 信息)。
读取逻辑:先判断格式,如果以 POINT / LINESTRING / POLYGON / MULTI 开头,按 WKT 解析;否则按 EWKB 十六进制解析。EWKB 解析的核心步骤:读取字节序 → 读取 type word → 提取 SRID(如果有)→ 重建标准 WKB → WKBReader.read()。
5.3 Mapper 层
Mapper 接口继承 MyBatis-Plus 的 BaseMapper,获得基础 CRUD 能力:
java
public interface PoiMapper extends BaseMapper<PoiEntity> {
}
对应的 XML 映射文件(resources/mapper/PoiMapper.xml):
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.pgd.postgisdemo.mapper.PoiMapper">
</mapper>
由于使用了 MyBatis-Plus 的自动 CRUD(getById / save / updateById / removeById),XML 中无需手写 SQL。
5.4 Service 层(业务逻辑收敛)
Service 层负责:Geometry 构造、WKT 解析、事务控制。Controller 只做 HTTP 协议转换。
服务接口
java
public interface PoiService extends IService<PoiEntity> {
Long addPoi(AddPoiReq req);
boolean updatePoi(UpdatePoiReq req);
boolean deletePoi(Long id);
PoiDetailResp getDetailById(Long id);
}
服务实现
java
@Service
public class PoiServiceImpl extends ServiceImpl<PoiMapper, PoiEntity>
implements PoiService {
private final WKTReader wktReader = new WKTReader();
private final WKTWriter wktWriter = new WKTWriter();
@Override
public Long addPoi(AddPoiReq req) {
try {
Geometry geom = wktReader.read(req.getGeom());
PoiEntity entity = new PoiEntity(
req.getName(), req.getCategory(), geom);
save(entity);
return entity.getId();
} catch (Exception e) {
throw new RuntimeException("Geometry 解析失败", e);
}
}
@Override
public boolean updatePoi(UpdatePoiReq req) {
try {
Geometry geom = wktReader.read(req.getGeom());
PoiEntity entity = new PoiEntity(
req.getName(), req.getCategory(), geom);
entity.setId(req.getId());
return updateById(entity);
} catch (Exception e) {
throw new RuntimeException("Geometry 解析失败", e);
}
}
@Override
public boolean deletePoi(Long id) {
return removeById(id);
}
@Override
public PoiDetailResp getDetailById(Long id) {
PoiEntity entity = getById(id);
if (entity == null) return null;
PoiDetailResp resp = new PoiDetailResp();
resp.setId(entity.getId());
resp.setName(entity.getName());
resp.setCategory(entity.getCategory());
resp.setGeom(wktWriter.write(entity.getGeom()));
return resp;
}
}
设计原则 :前端永远只传 WKT 字符串(如 "POINT(116.397 39.903)"),Geometry 的构造和解析全部收敛在 Service 层。Controller 不做任何空间逻辑。
5.5 Controller 层(HTTP 边界)
Controller 只负责接收请求、调用 Service、返回响应:
java
@RestController
@RequestMapping("/poi")
public class PoiController {
@Resource
private PoiService poiService;
@PostMapping("/add")
public R<Long> add(@RequestBody AddPoiReq req) {
return R.ok(poiService.addPoi(req));
}
@PostMapping("/update")
public R<String> update(@RequestBody UpdatePoiReq req) {
boolean ok = poiService.updatePoi(req);
return ok ? R.ok("更新成功") : R.fail("更新失败");
}
@DeleteMapping("/delete/{id}")
public R<String> delete(@PathVariable Long id) {
boolean ok = poiService.deletePoi(id);
return ok ? R.ok("删除成功") : R.fail("删除失败");
}
@GetMapping("/{id}")
public R<PoiDetailResp> detail(@PathVariable Long id) {
PoiDetailResp resp = poiService.getDetailById(id);
return resp != null ? R.ok(resp) : R.fail("POI 不存在");
}
}
六、接口测试示例
6.1 新增 POI
http
POST /poi/add
Content-Type: application/json
{
"name": "天安门",
"category": "景点",
"geom": "POINT(116.397 39.903)"
}


6.2 更新 POI
http
POST /poi/update
Content-Type: application/json
{
"id": 42,
"name": "天安门广场",
"category": "景点",
"geom": "POINT(116.3975 39.9035)"
}

6.3 删除 POI
http
DELETE /poi/delete/4

6.4 查询详情
http
GET /poi/42

七、踩坑复盘(重点)
7.1 MyBatis / MyBatis-Plus 自动配置冲突
- 问题现象 :启动时抛出
BeanDefinitionStoreException,提示 Mapper 注册冲突。 - 根因:MyBatis-Plus 3.5.7 自带 mybatis-spring 2.x(适配 Spring Boot 2.x),与 Spring Boot 3.x 不兼容。排除低版本并显式引入 mybatis-spring 3.0.3 后解决。
- 解决方案 ---
pom.xml中:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.7</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>
7.2 Maven Compiler Plugin 版本问题
- 问题现象 :
mvn compile失败,提示annotationProcessorPath的 version 不能为空。 - 根因:maven-compiler-plugin 3.11+ 要求 annotationProcessorPaths 中的每个依赖必须显式指定版本号。Lombok 的版本为空导致编译失败。
- 解决方案:简化 pom.xml,删除整个 maven-compiler-plugin 的自定义配置,只保留 spring-boot-maven-plugin。Spring Boot 3.2.5 会自动处理 Lombok 和 Java 17 的编译。
7.3 PostGIS JDBC 返回格式陷阱
- 问题现象 :TypeHandler 中
rs.getBytes()拿到的是 ASCII 字节(0x30= '0', 0x31='1'...),而不是原始二进制 WKB。 - 根因 :PostGIS JDBC 驱动默认将 geometry 列以十六进制字符串形式返回。
rs.getBytes()拿到的是每个十六进制字符的 ASCII 编码,不是二进制数据。 - 解决方案 :使用
rs.getString()获取十六进制文本,再手动 hexDecode 为二进制字节数组。或使用rs.getString()后判断是否为 WKT 格式,直接由 WKTReader 解析。
7.4 EWKB SRID Flag 解析
- 问题现象 :
WKBReader.read()抛出ParseException: Unknown WKB type 592 / 619 / 524。 - 根因:EWKB(Extended WKB)在 type word 的 bit 29 上设置了 hasSRID 标志位(0x20000000)。直接从 EWKB 中提取坐标数据后,缺少标准 WKB 的 type word,导致 WKBReader 解析错位。
- 解决方案:解析 EWKB 时,提取 SRID 后,将剩余坐标数据前加上标准 WKB 的 byte order + type word(清除 hasSRID 标志位),重建标准 WKB 再交给 WKBReader 解析。
7.5 TypeHandler 生效条件
- 问题现象 :
entity.getGeom()始终为 null,即使配置了@TableField(typeHandler = ...)。 - 根因 :MyBatis 中,手写 SQL +
resultType不会自动应用@TableField上声明的 TypeHandler。只有以下三种方式能确保 TypeHandler 生效:- MP 自动 CRUD +
@TableName(autoResultMap = true) - 手写 resultMap 并在
<result>中显式指定 typeHandler - 使用
@TableField(value = "ST_AsBinary(geom)")指定 SQL 表达式
- MP 自动 CRUD +
- 推荐方案 :使用 MP 自动 CRUD(getById / save / updateById / removeById)+
autoResultMap = true,最简洁、最不容易出错。
八、最佳实践总结
架构分层
- Controller 只做 HTTP 协议转换 + 参数校验
- Service 负责 Geometry 构造、WKT 解析、事务控制
- 前端永远只传 WKT 字符串,不直接操作 Geometry
- Entity 是数据库模型,DTO 是前端契约,职责分离
GeometryTypeHandler 设计原则
- 写入:使用
WKBWriter(2, LITTLE_ENDIAN, true)生成 EWKB - 读取:先判断 WKT / EWKB 格式,分别处理
- EWKB 解析:提取 SRID → 重建标准 WKB → WKBReader.read()
- 不依赖 PGgeometry,不依赖 PGgeometry.toString()
MyBatis-Plus 使用建议
- 优先使用 MP 自动 CRUD,减少手写 SQL
- Entity 上必须加
@TableName(autoResultMap = true) - Geometry 字段必须加
@TableField(typeHandler = GeometryTypeHandler.class) - 排除 MyBatis-Plus 自带的低版本 mybatis-spring,引入 3.0.x