ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+GraphHopper实现多途经点路径规划与顺序优化

SpringBoot+GraphHopper实现多途经点路径规划与顺序优化

简介:一套基于GraphHopper与Java SpringBoot构建的多途径点路径规划源码项目,面向需要实现路径计算、途经点顺序优化等功能的Java开发者,适用于物流配送、城市交通、户外导航等场景。项目借助GraphHopper图形处理引擎,配合SpringBoot简化配置与部署流程,可在多途经点场景下解算满足最短距离或最短时间等约束的候选路径。压缩包共23个文件,约31.72MB,以4个Java源文件为核心,另有2个OSM街道地图数据、3个属性文件、1个JAR包,以及Maven wrapper、Git忽略文件等工程辅助内容,目录结构简练,便于直接导入并定位关键模块。当前已有325人学习浏览。学习该资源,可掌握GraphHopper与SpringBoot的集成方式,理解地图数据、配置文件、路径计算及索引存储等模块之间的协作关系,并基于现有骨架调整途经点策略,扩展到实际路径规划业务中。

1. 多途径点路径规划:顺序不对,算得再准也白搭

做 Java SpringBoot 路径规划时,很多人第一反应是把途经点按地址列表顺序一条条连起来。但实际跑过几轮真实数据就会发现,这个顺序几乎永远不是最优解——配送路线、巡检点位、车辆调度里真正的耗时大头,是先搞清楚“先去哪、后去哪”,再计算“相邻两点间怎么走”。GraphHopper 是一个开源的 Java 路由引擎,能基于 OSM 路网数据做最短路径、等时圈、逆地理编码,而这份基于 GraphHopper 的 SpringBoot 多途径点路径规划设计源码,解决了两个叠加问题:途经点顺序怎么优化,以及优化后整条路线怎么在路网上正确复现。适合正在做物流调度、外勤巡检、车辆路径规划相关后端开发的人直接拿来当脚手架。

2. Java SpringBoot 工程结构:GraphHopper 引擎选型与初始化方式

2.1 为什么选 GraphHopper 而不是自研算法或在线 API

自研最短路径算法不是不行,而是代价太明显。你需要自己处理路网拓扑、道路单向限制、转弯cost、不同交通工具的禁行规则、地图匹配,甚至还要维护一套足够细的路网数据。做巡检或配送系统时,这些基础能力如果全部从零开始,开发周期会拉得很长。

在线 API 的问题是数据出域和请求配额。对内部系统来说,把客户位置或车辆轨迹发到外部服务,很多场景在合规和技术上都不好接受。GraphHopper 是纯 Java 实现的开源引擎,加载 OSM 路网数据后全部在本地跑,请求不出内网,也不需要按次付费,这个特性在项目里是硬加分项。

GraphHopper 内置了 A*、双向 Dijkstra、ALT 等多种路由算法,支持 foot、car、bike 等编码方式,并且加载后的路网会生成一份只读快照,多线程查询不会互相写坏数据。结合 SpringBoot 做接口层,整体结构非常契合。

2.2 工程目录与 Maven 核心依赖

这份源码的目录结构不复杂,核心模块就三层:配置层、计算服务层、接口层。配置层负责读取路网文件路径、存储位置、编码方式,计算服务层封装 GraphHopper 引擎实例,接口层暴露 HTTP API。

pom.xml 里关键依赖就三块,不用堆一堆没用的包:

<properties> <graphhopper.version>保持与源码包内一致</graphhopper.version> </properties> <dependencies> <dependency> <groupId>com.graphhopper</groupId> <artifactId>graphhopper-core</artifactId> <version>${graphhopper.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

注意 graphhopper-core 和 graphhopper-web 这两个模块的区别。web 模块自带一套完整的 HTTP 服务端,但定制接口不方便,塞进 SpringBoot 会感觉像在抢控制权。这里只用 core 模块,把引擎当普通库注入到 Spring 容器里,接口完全用 SpringMVC 自己控制。

如果后续要用 isochrone(等时圈)或地图匹配功能,再按需加 graphhopper-isochrone、graphhopper-map-matching 对应依赖,不需要现在就全部引入。

2.3 GraphHopper 引擎初始化与路网数据导入

引擎初始化是整个项目里唯一一个“必须耐心等”的步骤。首次启动时,GraphHopper 会读取.osm.pbf路网文件,解析节点、边、道路属性,构建存储索引,这个过程根据路网大小可能持续几十秒到几分钟。初始化之后生成的索引会落在graph.location指定的目录里,后续启动可直接复用,不再重复解析。

看这个初始化逻辑:

@Configuration public class GraphHopperConfig { @Bean public GraphHopper graphHopper() { GraphHopper hopper = new GraphHopper(); hopper.setOSMFile("/data/osm/area.osm.pbf"); hopper.setGraphHopperLocation("/data/gh-storage"); hopper.setEncodedValueString("car_access, car_average_speed"); hopper.setProfiles( new Profile("car").setVehicle("car").setWeighting("fastest") ); hopper.getRouterConfig().setMaxVisitedNodes(1_000_000); hopper.importOrLoad(); return hopper; } }

参数含义逐条说清:setOSMFile指定原始路网数据文件路径,首次导入必须存在;setGraphHopperLocation是索引文件的落盘目录,这个目录一旦生成就不要手动删改;setEncodedValueString声明需要写入图存储的属性,car_access 表示该边是否允许汽车通行,car_average_speed 表示通行速度,这两个是路径计算的基础;setProfiles里的 weighting 支持 fastest 和 shortest,做配送路径通常选 fastest,因为距离短的路线可能因为红绿灯和限速导致实际更慢;importOrLoad()会判断存储目录里是否已有索引,有就直接加载,没有就构建,所以重复启动不会重复解析。

常见错误是路径写错时项目启动直接报错,而超时时间和 OSM 解析进度都不够直观。我的习惯是先跑一次纯 Java 测试类,单独初始化引擎复制一遍启动过程,把日志里的loaded graph at ...和graph edge count这两行确认好,再启动 SpringBoot 整体服务,能省很多排查时间。

2.4 Service 层封装与单实例复用

GraphHopper 实例在 Spring 容器里应该是单例,不要每次请求都 new 一个。引擎加载索引后整体占用内存较高,多实例等于给自己制造 OOM 风险。Service 层只需要注入这个单例,再封装出最基本的路线查询方法:

@Service public class RouteService { private final GraphHopper graphHopper; public RouteService(GraphHopper graphHopper) { this.graphHopper = graphHopper; } public GHResponse calculateRoute(List<GHPoint> points) { GHRequest request = new GHRequest(points); request.setProfile("car"); request.setAlgorithm(AlgorithmOptions.DIJKSTRA_BI); return graphHopper.route(request); } }

GHRequest 接收按顺序排列的坐标点列表,这里的顺序非常关键,之后做多途径点优化时,所有优化结果都会从这一个方法入口进去。setProfile("car")必须和初始化时定义的 profile 名称一致,写错会直接抛 ProfileNotFound 异常。

DIJKSTRA_BI是双向 Dijkstra,GraphHopper 里默认的 fast 算法实际上是 A* 变体,多途经点的局部路段计算用双向 Dijkstra 往往更稳,不容易出现启发函数过估导致的意外绕路。

3. 多途径点核心实现:顺序求解与逐段路径拼接

3.1 途经点数据结构与坐标预处理

多途径点计算的第一步不是调算法,而是清洗坐标数据。实际项目里拿到的坐标来源很杂,可能是 Excel 里的经纬度、手机 GPS 上报的 WGS84 坐标,甚至可能是手填的错误数据。起点写反、经纬度写反、坐标超出路网边界,这些情况如果不在预处理阶段拦住,后面计算出来的路径就是一条比直线更扭曲的无效路线。

先定义一个干净的坐标点结构:

public class Waypoint { private String name; private double lat; private double lng; private boolean required; public GHPoint toGHPoint() { return new GHPoint(lat, lng); } }

required 字段表示这个点是否必须经过。有些场景下某些点是软约束,实在无法经过时可以跳过;但绝大多数业务里所有点都是硬约束,必须全部落在最终路径上。

坐标校验的规则有三个:纬度范围 -90 到 90,经度范围 -180 到 180;点与点之间距离不能太近,小于 20 米的重复点直接合并;所有点必须在路网数据覆盖的边界框内。第三点最容易漏,假设你加载的是某个城市的路网,却传入几百公里外的坐标,GraphHopper 会把距离最近的路段当成匹配结果,造成那种“起点明明在 A 城,路径却从 B 城边缘开始”的诡异翻车。

3.2 途经点顺序优化:贪心初始化与 2-opt 迭代优化

多途经点顺序问题本质上是 TSP(旅行商问题)的变体。途经点少于 8 个时,直接暴力穷举所有排列选最小总距离是可行的;一旦超过 10 个,排列数指数级增长,就不能再硬算了。常见做法是贪心算法先算一个初解,再用 2-opt 局部搜索迭代收敛。

我把这一整块逻辑封装成一个独立的顺序优化器:

public class RouteOptimizer { public List<Waypoint> optimize(List<Waypoint> waypoints) { List<Waypoint> route = new ArrayList<>(waypoints); route = nearestNeighborInit(route); route = twoOptImprove(route); return route; } private List<Waypoint> nearestNeighborInit(List<Waypoint> points) { List<Waypoint> result = new ArrayList<>(); Set<Integer> visited = new HashSet<>(); int current = 0; result.add(points.get(current)); visited.add(current); while (result.size() < points.size()) { int next = -1; double bestDist = Double.MAX_VALUE; for (int i = 0; i < points.size(); i++) { if (visited.contains(i)) continue; double d = haversineDistance( points.get(current).getLat(), points.get(current).getLng(), points.get(i).getLat(), points.get(i).getLng()); if (d < bestDist) { bestDist = d; next = i; } } result.add(points.get(next)); visited.add(next); current = next; } return result; } private List<Waypoint> twoOptImprove(List<Waypoint> route) { boolean improved = true; while (improved) { improved = false; for (int i = 1; i < route.size() - 2; i++) { for (int j = i + 1; j < route.size() - 1; j++) { double before = segmentDistance(route, i - 1, i) + segmentDistance(route, j, j + 1); double after = segmentDistance(route, i - 1, j) + segmentDistance(route, i, j + 1); if (after < before - 1e-6) { reverseSegment(route, i, j); improved = true; } } } } return route; } }

这个优化器里有两个关键点。nearestNeighborInit是贪心最近邻初始化,从第 0 个点开始,每一步选择离当前点最近的未访问点加入路线,复杂度从阶乘级降到了平方级,能在 1 秒内生成一个不算差的初始解。twoOptImprove是核心优化步骤,它的原理是选定路径中两个不相邻的边,如果交换这两条边的连接方式使局部路径总长更短,就把这两段之间的子路径反向翻转,反复迭代直到无法改进。

这里用 haversineDistance 计算的是球面直线距离,而不是路网距离。原因很简单:顺序优化的每个候选方案都要评估距离,如果每评估一次都调 GraphHopper 算一遍完整路网路径,查询次数会爆炸,性能完全不现实。直线距离做初筛已经足够分出明显的优劣顺序。真正的路网校验只用在最终选定顺序后,逐段调用 GraphHopper 拼接完整路径。

3.3 按优化顺序拼接完整路网路径

顺序确定后,就要把相邻途经点两两配对,逐段调用 GraphHopper 做路径计算,再拼接成整条路线。拼接分段路径的核心诉求有两个:保证每一段都走合法路网路径,以及保证整条路径的几何表达连续。

这里有一个容易忽略的技术细节:GraphHopper 的分段路径结果里,第一段的终点恰好是第二段的起点,这个共享点会重复出现,拼接时需要去重。如果不去重,前端绘制轨迹时会看到某个途经点处出现一个不正常的“小分叉”。

public GeoJsonRoute buildOptimizedRoute(List<Waypoint> optimizedPoints) { List<double[]> fullPath = new ArrayList<>(); double totalDistance = 0; long totalTime = 0; for (int i = 0; i < optimizedPoints.size() - 1; i++) { GHPoint from = optimizedPoints.get(i).toGHPoint(); GHPoint to = optimizedPoints.get(i + 1).toGHPoint(); GHRequest request = new GHRequest(from, to); request.setProfile("car"); request.setAlgorithm(AlgorithmOptions.DIJKSTRA_BI); GHResponse response = graphHopper.route(request); if (response.hasErrors()) { throw new IllegalStateException("路径计算失败: " + response.getErrors()); } ResponsePath path = response.getBest(); PointList points = path.getPoints(); for (int j = (i == 0 ? 0 : 1); j < points.size(); j++) { fullPath.add(new double[]{points.getLat(j), points.getLng(j)}); } totalDistance += path.getDistance(); totalTime += path.getTime(); } return buildRouteResult(fullPath, totalDistance, totalTime); }

分段计算时for (int j = (i == 0 ? 0 : 1); j < points.size(); j++)这个细节决定了拼接质量。第一段路径从头取到尾,后续每一段都从索引 1 开始,目的是跳过上一段的最后一个点,避免同一坐标重复出现在轨迹数组里。

Path.getDistance() 返回的是该段路的真实路网行驶距离,单位是米;getTime() 返回预估耗时,单位是毫秒。累计到 totalDistance 和 totalTime,最后给前端返回一个完整的路径结果。

3.4 响应结构与前端联动

后端接口返回的 JSON 结构建议固定成下面这种格式,前端拿到的数据足够完整又不冗余:

{ "code": 0, "data": { "distanceMeters": 58362.4, "timeMillis": 2430000, "waypointOrder": [3, 0, 2, 1], "path": [ [39.9115, 116.4521], [39.9120, 116.4528] ] } }

waypointOrder 是原始途经点列表重新排序后的下标数组,前端可以根据这个数组重新标注途经点编号,在页面上很直观地看到优化前后的顺序差异。path 是轨迹坐标点数组,可以直接喂给地图 SDK 画线。

这一层设计还有一个小优化空间:如果整条路线只有 2 到 5 个途经点,可以不走贪心算法,直接暴力枚举全排列,取 GraphHopper 真实路网距离最小的一组。点少时真实路网距离和直线距离的排序偶尔不一致,暴力枚举能拿到更精确的最优。我是用分支判断实现两种计算路径自动切换,代码不复杂但很实用。

4. 多途径点落地避坑:绕路、内存、骑行模式与并发延迟

4.1 坐标在路网边缘时出现的绕路和穿湖现象

第一类常见翻车是某个途经点位于路网边界附近,计算出来的路径直接出现长距离绕路甚至“横穿水面”的诡异线段。

原因不在 GraphHopper 的算法本身,而在于坐标匹配逻辑。GraphHopper 查找最近边时会基于空间索引找物理距离最近的边,如果那个点正好落在路网裁剪边界外侧,而距离最近的一条路是远处某条长路,路径就会从那个匹配点硬拉一条长直线过去。

解决方法是设置最大匹配距离,超过阈值直接报错而不是静默匹配:

Snap conflict resolution doesn't require extra code, but the threshold check does. double maxSnapDistance = 500; if (snap.distance > maxSnapDistance) { throw new IllegalArgumentException("坐标点 " + wp.getName() + " 距离路网过远"); }

我一般把阈值设为 300 到 500 米。超过这个范围说明输入坐标可能本身有问题,要么是经纬度写反,要么是坐标落到了未覆盖的区域。提前暴露错误,比中途生成一条离谱路径要好得多。

4.2 启动时的内存与磁盘异常

GraphHopper 导入较大路网时内存占用和磁盘占用都可能突然跳高,尤其是用默认配置跑整个省份级别的数据,16G 内存的机器会直接卡死。

原因是 graph.location 目录里会生成大量索引文件,同时 import 阶段需要大量堆内存做节点排序和边压缩。OSM 文件本身只有几百 MB,构建后的索引文件可能膨胀到几个 GB。有些人拿到源码后直接启动,日志停在同一行十几分钟,以为程序死锁了。

处理方式是在启动脚本里显式设置 JVM 堆内存参数,并控制路网数据范围。如果你只需要某个城市内的路径规划,就在导入前用 OSM 处理工具把数据裁剪到边界框内,不要导入整个省或整个国家的数据。源码里默认配置我一般会调整成这样:

-Xms4g -Xmx8g

如果数据量确实大,机器内存又有限,就选择减少编码器数量。只做汽车路径规划时,不要同时初始化 foot 和 bike 编码器,每个编码器都会额外占用内存和磁盘空间。这个用不到的功能就别开,资源省得很实在。

4.3 骑行模式比想象中更容易绕路

项目里如果同时做了骑行路径规划,很容易在测试阶段发现骑行结果明显绕路或干脆无法连通。同一对起终点,驾车 5 公里,骑行却给出了 14 公里的路径。

原因是骑行模式下路网过滤器更严格。自行车在 OSM 路网里对部分道路类型不可通行,天桥、隧道、高速路都进不去,而数据源里如果自行车道标注不完善,引擎会倾向性地选择绕大圈走可通行道路。还有一个让新手容易懵的点是骑行配置里默认禁用了部分步行骑行共用道,导致结果看起来比预想的绕。

遇到这个情况,我一般会先检查 profile 名称是否写对,GraphHopper 里 bike、mtb、racingbike 三种骑行模式用的道路过滤规则不同,racingbike 对铺装路面要求更高。然后是导入时基础编码器必须包含 bike_access 和 bike_average_speed,如果只初始化了 car_access,骑行路径引擎能跑但在某些点上会生成非常奇怪的降级行为。数据源覆盖不完整时,骑行路径体验差不是引擎问题,需要先从数据源本身排查。

4.4 高并发请求下的偶发延迟与 GC 停顿

接口在压测或真实上线后可能会出现偶发的高延迟,单看某一次请求又找不到明显的逻辑问题。这不是 GraphHopper 查询本身不够快,而是引擎持有的大内存数据结构在高并发下触发了 GC 停顿。路网索引是只读的,但节点对象和边对象数量巨大,GC 时每次全堆扫描都很耗时。

常规处理方式有三个。第一,启动完成后先做预热,发几条覆盖不同区域的路由请求,让 JIT 编译热点代码,同时触发首次数据加载。第二,控制查询接口的并发量,配合缓存做结果复用,相同起终点组合的请求直接从缓存返回。第三,把顺序优化计算和 GraphHopper 路径查询分离到不同线程池,避免计算密集型的顺序优化阻塞路由查询。

private final ExecutorService routePool = Executors.newFixedThreadPool(8);

线程数量和 CPU 核数保持一致,不要开太多。GraphHopper 单实例并发查询本身是线程安全的,但线程多了反而会因为上下文切换和内存带宽限制变慢,8 到 16 个业务线程通常就是最优区间。

4.5 路径结果里的掉头与折返

路径计算完成后,仔细查看轨迹会发现某个途经点附近出现了一次明显的掉头或折返,前后两段路方向相反,像是司机开过头再倒回来。这个问题从算法角度看不影响可达性,但业务层面很不好接受。

出现掉头通常是因为 GraphHopper 的算法允许 U-turn,且 U-turn 的转弯成本设置得偏低。最常见原因是途经点匹配到了一条主干道中间的某条边,算法为了从这个点顺路接上下一段路径做出了绕行返折,比从前方路口掉头更“近”。优化方式是把 U-turn 成本调高,同时给途经点指定期望方向:

request.setHeadings(List.of(90.0, 180.0, 270.0, 0.0)); request.setHeadTolerance(90.0);

headings 数组对应每个途经点的期望朝向角,0 表示向北,90 向东,180 向南,270 向西。如果业务里不清楚每个点的朝向,就不建议强行设置,因为错误的朝向角会导致匹配点偏移更严重。最常见有效的处理是只用 U-turn 成本调节:

hopper.getRouterConfig().setUTurnCosts(60);

这个参数的单位和 weighting 方案有关,fastest 下每个 U-turn 相当于增加若干秒或等效延迟。调整到 60 之后,多数不必要的掉头都会被代价惩罚掉,终点处正常调头不受影响。

5. 进阶验证:用三种距离对比判断顺序优化是否真的生效

很多人做完多途经点优化后,只看前端轨迹“好像更顺了”,但“更顺”没有量化依据。我建议每次改完逻辑都跑一遍三距离对比脚本,用数字说服自己优化器确实在干活。

三种距离分别是:纯直线总距离,按输入顺序经过所有点的路网总距离,优化顺序后的路网总距离。对比逻辑很简单,优化前的路网距离除以优化后的路网距离,得到一个优化系数,大于 1 说明优化有效,数值越大越明显。

optimized = 58362.4 original = 74215.1 straight_line = 38420.0 ratio = original / optimized print(f"优化系数: {ratio:.2f}") print(f"优化节省: {original - optimized:.1f} 米") print(f"直线距离占比: {optimized / straight_line:.2f}")

这个脚本跑出来的数据能同时验证两件事。优化系数如果小于 1.05,说明这组途经点的原始顺序已经比较好了,2-opt 没有找到明显的更优排列,这是正常的;但如果多组数据都小于 1.05,就要回头检查贪心初始化是不是出了问题,比如起点固定逻辑写死导致初解质量太差。直线距离占比反映的是路网曲折度,一般在 1.3 到 2.5 之间算正常,高于 3 则说明数据中可能有跨区域长距离绕路,需要回到数据检查步骤。

还有一个进阶验证值得做:把优化器生成的顺序和原始顺序分别请求完整路由,对比两段的每段距离明细。因为优化器用的是直线距离排序,少数情况下会出现“路网实际距离更短,但直线距离更长”的相邻点替换,暴力枚举点数少时可以补上这个精确校验。

我第一次跑对比脚本时发现某组 12 个点的数据优化系数只有 1.03,当时以为优化器有 bug。后来逐段检查才发现这组点本身排列已经接近最优,说明优化器没有错误地破坏已有良好顺序。从那以后,我每次拿到新路网或新数据集,都会强制先跑一遍这组三距离对比,再决定是否需要继续调参,而不是依赖肉眼判断。这套方法配合这份源码用起来很顺手,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进