简介:面向Java开发者的多途径点路径规划系统源码,基于SpringBoot框架集成GraphHopper引擎,用于解决物流配送、户外导航等需要同时优化多个途经点顺序与路径的实际问题,系统已具备基础路径计算与多途经点排序能力。压缩包共23个文件,大小约31.72MB,包含4个Java源文件、2个OSM地图数据文件、配置文件与依赖JAR包等,并配有Maven封装脚本和Git忽略配置,目录分层清晰,便于直接导入IDE运行与二次改造。目前该资源已有325人学习下载。通过研读源码,可掌握GraphHopper路径计算API的调用方法、SpringBoot工程结构与地图数据加载流程;项目中针对多途经点场景的建模思路、途经点顺序优化策略以及资源配置方式,亦可作为毕业设计、课程实验或企业级路径规划服务开发的实用参考。通过修改配置与扩展接口,能快速适配不同城市或业务约束,缩短路径规划功能的落地周期。
1. 多途径点路径规划,真正的复杂度不在“找路”而在“排单”
我做过一个配送相关的模拟项目X:每天上午要把几十个客户点分给几辆车。原来靠人按区域顺手排单,最典型的翻车是:两个点明明在同一条街,司机却先送了小区另一头,最后多绕了四十分钟。当时把方案改成基于GraphHopper的Java SpringBoot多途径点路径规划设计源码,才算把两件事分开:GraphHopper负责“怎么走”,VRP组件负责“按什么顺序走”。这套思路适合需要自己控制服务端路线、不想把订单数据完全交给外部地图服务的团队。你真正要面对的核心不是路径算法本身,而是矩阵规模、约束建模和结果校验。
2. 先拆需求:GraphHopper 在这套源码里是“计算员”,不是“调度员”
2.1 多途径点问题的两种拆法:逐个寻路为什么行不通
多途径点路径规划最容易被低估的一点是:它不等于“把多个点串起来调一次导航”。如果你只有三五个固定顺序的点,直接从 A 到 B、B 到 C、C 到 D 连续调 GraphHopper 的 route 接口,确实能得到一条折线。但当点变成十几个、车辆变成好几辆、每个点还有时间要求时,“顺序”本身就是决策变量。
逐个寻路的问题在于它天然假设顺序已给定。实际业务里,司机从仓库出发先送哪个客户,直接决定总里程。说得直白一点,这个问题是 N 个点的排列组合,不是一条路径。用 GraphHopper 算一对点到点路径很快,但算 20 个点的所有两两关系就不是几十次 route 调用,而是 20×20 次矩阵计算。所以业内做法是先让 GraphHopper 给出“任意两点之间的成本”,再把成本交给 VRP 求解器去决策顺序。
这个拆分也解释了为什么标题里叫“规划设计源码”而不叫“路径绘制源码”:规划的核心是求解订单顺序,绘制只是最后一步。GraphHopper 在这里承担的是底层路网能力,它提供距离、时间、路况相关的成本数据;真正决定“先送谁”的是一套独立的优化引擎。
2.2 把 GraphHopper 和优化引擎分开:一个管路网,一个管订单
我见过一些团队把排序逻辑直接写在 GraphHopper 的回调里,看起来省事,后续却很难改。比如今天只有 3 辆车、明天变成 5 辆,或者某个客户要求“中午十二点前必须送到”,这些约束如果和路径查询代码混在一起,每次需求变更都要动核心链路。
常见做法是拆成四个模块。RoutingService 负责调用 GraphHopper 返回一条带 GPS 轨迹的路径;MatrixService 负责批量算两两成本;VrpSolver 负责接收矩阵和车辆约束,输出每个车辆的途经点顺序;RoutePlanController 负责任务编排和结果组装。
这套分层的理由是:矩阵计算和 VRP 求解的失败模式完全不同。矩阵是纯计算,性能瓶颈在网络和内存;VRP 是组合优化,瓶颈在约束设置和迭代次数。把它们放在同一个类里,出现问题你很难定位是路网数据问题还是算法不收敛。分开以后,我可以单独缓存矩阵,也可以把 VRP 丢到线程池里异步跑。
GraphHopper 本身也提供在线版 Route Optimization API,但如果你是希望“源码可控、离线可跑”,我更推荐本地矩阵加本地求解器。GraphHopper 团队维护的 JSprit 分支是 Java 生态里比较成熟的 VRP 组件,和 GraphHopper 的数据模型配合起来很顺,下面所有例子都按这条路线展开。
2.3 SpringBoot 项目目录结构和 Maven 依赖:先搭骨架再调算法
之前接手的一个项目里,我按下面的目录结构组织代码,效果不错。写这套结构的核心原则是:让每个类只回答一个问题。
com.example.routeplanner ├── config │ └── GraphHopperAutoConfiguration.java ├── dto │ ├── RoutePlanRequest.java │ ├── StopPosition.java │ └── VehicleConfig.java ├── matrix │ └── MatrixService.java ├── route │ └── RoutingService.java ├── vrp │ └── VrpSolver.java ├── web │ └── RoutePlanController.java └── RoutePlannerApplication.javadto 里不直接使用 GraphHopper 的 GHPoint,而是用自己的 StopPosition。原因是 GHPoint 只是坐标,没有订单 ID、需求量、时间窗这些业务字段;一旦业务扩展,你已经把对象传染到所有接口。GraphHopper 升级时也只动 config 和 route 两个包,不会影响上面的控制器和求解器。
Maven 依赖这样加:
<properties> <graphhopper.version>你的版本号</graphhopper.version> <jsprit.version>你的版本号</jsprit.version> </properties> <dependencies> <dependency> <groupId>com.graphhopper</groupId> <artifactId>graphhopper-core</artifactId> <version>${graphhopper.version}</version> </dependency> <dependency> <groupId>com.graphhopper</groupId> <artifactId>jsprit-core</artifactId> <version>${jsprit.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>版本号没有写死,我一般只维护 properties 里的一个变量。GraphHopper 的小版本升级偶尔会改 Matrix 内部接口,集中管理能让你升级时只翻一个文件。下面代码里如果出现个别类名和你当前版本不一致,先回来看这个依赖声明,多半是版本差异。
3. 先把 GraphHopper 跑起来:SpringBoot 里加载 OSM 与路线查询
3.1 用 application.yml 管好 PBF、缓存目录与 profile
GraphHopper 核心需要一份地图数据。生产环境常用 OpenStreetMap 的 PBF 文件,一个城市几 GB 到几十 GB 不等。第一次启动要解析并构建路网索引,所以必须指定一个缓存目录,否则每次重启都在重新建图。
routeplanner: graphhopper: osm-file: /data/city-latest.osm.pbf profile: car cache-dir: /data/gh-cache matrix-timeout-seconds: 30 vrp: max-iterations: 500profile 指的是出行方式,常见有 car、bike、foot。这里最值得注意的是 cache-dir。GraphHopper 导入后会把图数据序列化到本地,第二次启动直接读缓存,速度能差十几倍。生产环境不要把 cache-dir 放在临时目录,否则重启就丢。matrix-timeout-seconds 是给矩阵计算的 HTTP 层超时,防止请求量大的时候把线程池拖死。
3.2 Java Config 初始化 GraphHopper
用 Spring 的 @Configuration 把 GraphHopper 声明成单例 Bean。GraphHopper 内部有线程安全的路由查询能力,做成 Spring 单例是合理的;不要每次请求都 new 一个,初始化建图非常重。
@Configuration public class GraphHopperAutoConfiguration { @Bean public GraphHopper graphHopper( @Value("${routeplanner.graphhopper.osm-file}") String osmFile, @Value("${routeplanner.graphhopper.profile}") String profile, @Value("${routeplanner.graphhopper.cache-dir}") String cacheDir) { GraphHopperConfig config = new GraphHopperConfig(); config.put("graphhopper.datareader.file", osmFile); config.put("graphhopper.graph.location", cacheDir); config.put("graphhopper.profiles", profile); GraphHopper hopper = new GraphHopper(); hopper.init(config); hopper.importOrLoad(); return hopper; } }逻辑说明:importOrLoad()是关键,它会先看 cache-dir 有没有可用的图缓存,有就直接加载,没有才解析 PBF。参数里graphhopper.datareader.file是地图文件,graphhopper.graph.location是缓存目录,graphhopper.profiles决定启用哪些出行方式的编码器。如果你的内网环境要频繁发布,建议启动时做一次预热路由查询,避免第一个请求承担建图后的首次编译开销。
3.3 先验证单点寻路:控制器与返回结果
多途径点规划再复杂,最后落到地图上还是要靠点对点路径。所以先做一个最简单的路由接口,验证 GraphHopper 数据已经能正常使用。
@RestController @RequestMapping("/api/route") public class RoutePlanController { private final RoutingService routingService; public RoutePlanController(RoutingService routingService) { this.routingService = routingService; } @PostMapping("/simple") public RouteResult simpleRoute(@RequestBody RouteRequest request) { GHPoint from = new GHPoint(request.fromLat(), request.fromLon()); GHPoint to = new GHPoint(request.toLat(), request.toLon()); GHRequest ghRequest = new GHRequest(from, to); ghRequest.setProfile("car"); GHResponse response = routingService.route(ghRequest); if (response.hasErrors()) { throw new IllegalStateException(response.getErrors().toString()); } Path path = response.getBest(); return new RouteResult(path.getDistance(), path.getTime(), path.getPoints()); } }这里我故意用 GHResponse 直接返回,目的是先验证数据链路。生产中我会在 RoutingService 里把 response 转成自己的 DTO,原因前面说过:避免 GHResponse 的字段结构泄漏到 Controller。参数setProfile("car")必须和 application.yml 里配置的 profile 一致,否则 GraphHopper 会直接报“找不到走向量”。这一步跑通后,再进去多途经点规划才不会被底层路网问题干扰。
4. 多途径点规划的完整链路:矩阵 + 车辆路径求解器
4.1 建矩阵:从 N 个点得到距离和时间方阵
多途径点规划的第一步是算矩阵。比如有仓库和 19 个客户点,一共 20 个点,就需要 20×20 的成本矩阵。GraphHopper 的 Matrix 模块可以一次请求返回多条路线成本,比循环调用 route 高效得多。
@Service public class MatrixService { private final GraphHopper graphHopper; public MatrixService(GraphHopper graphHopper) { this.graphHopper = graphHopper; } public MatrixResult buildMatrix(List<StopPosition> stops, String profile) { List<GHPoint> points = stops.stream() .map(s -> new GHPoint(s.latitude(), s.longitude())) .toList(); MatrixRequest request = new MatrixRequest(); request.setProfile(profile); request.setPoints(points); request.setOutArrays(new String[]{"time", "distance"}); Matrix matrix = new Matrix(graphHopper); MatrixResponse response = matrix.calc(request); return new MatrixResult(response.getDistanceArray(), response.getTimeArray()); } }参数说明:setOutArrays决定矩阵里返回哪些成本,我一般同时要 distance 和 time。distance 用来做油耗和里程目标,time 用来做时间窗约束。返回的数组是二维方阵,下标和 stops 列表顺序一致。如果你的 GraphHopper 版本较新,Matrix 类的构造方式可能略有变化,但核心逻辑不变:先构造 points,再取二维结果。
注意一点:矩阵计算的点数越高,内存占用增长越快,20 个点是 400 格,50 个点是 2500 格。对 SpringBoot 应用来说,几百个点的矩阵还能接受,超过一千点就要考虑分治或聚类了。
4.2 用成本矩阵驱动 JSprit 求解:车辆数、顺序与容量
拿到矩阵后,真正的多途径点决策交给 VRP 求解器。下面这段代码是核心,我会把车辆和客户点都放进 JSprit 的模型中,并用矩阵实现运输成本。
@Service public class VrpSolver { public VrpSolution solve(List<StopPosition> stops, List<VehicleConfig> vehicles, MatrixResult matrix) { VehicleRoutingProblem.Builder vrpBuilder = VehicleRoutingProblem.Builder.newInstance(); vrpBuilder.setRoutingCost(new MatrixCosts(matrix.distanceArray(), matrix.timeArray())); int vehicleId = 0; for (VehicleConfig v : vehicles) { VehicleTypeImpl type = VehicleTypeImpl.Builder.newInstance("type_" + vehicleId) .addCapacityDimension(Dimensions.Tasks, v.maxStops()) .build(); VehicleImpl vehicle = VehicleImpl.Builder.newInstance("vehicle_" + vehicleId) .setStartLocation(Location.Builder.newInstance("0") .setCoordinate(Coordinate.newInstance(v.startLon(), v.startLat())) .build()) .setEndLocation(Location.Builder.newInstance("0") .setCoordinate(Coordinate.newInstance(v.endLon(), v.endLat())) .build()) .setType(type) .build(); vrpBuilder.addVehicle(vehicle); vehicleId++; } for (int i = 1; i < stops.size(); i++) { StopPosition stop = stops.get(i); Service service = Service.Builder.newInstance("customer_" + i) .setLocation(Location.Builder.newInstance(String.valueOf(i)) .setCoordinate(Coordinate.newInstance(stop.longitude(), stop.latitude())) .build()) .addSizeDimension(Dimensions.Tasks, stop.demand()) .build(); vrpBuilder.addJob(service); } VehicleRoutingProblem problem = vrpBuilder.build(); VehicleRoutingAlgorithm algorithm = Jsprit.createAlgorithm(problem); algorithm.setMaxIterations(500); VehicleRoutingSolution solution = algorithm.searchSolution(); return decode(solution, stops); } }逻辑说明:矩阵下标 0 通常表示仓库或起点,所以车辆 start 和 end 都挂在 index 0 上。每个客户点的 Location id 直接使用 stops 里的下标,这样 MatrixCosts 才能映射成本。Dimensions.Tasks是 JSprit 自带的维度,我用它来限制每辆车最多跑几个点;如果不限容量,算法会把所有点塞给一辆车。setMaxIterations(500)控制求解质量,值越大结果越好,但耗时线性上涨。前期调试用 100,上线可以调到 500 以上。
4.3 把矩阵成本映射成 JSprit 需要的运输成本
JSprit 本身不知道 GraphHopper 矩阵是什么,它只认VehicleRoutingTransportCosts接口。所以写一个 MatrixCosts 适配器:
public class MatrixCosts implements VehicleRoutingTransportCosts { private final double[][] distance; private final double[][] time; public MatrixCosts(double[][] distance, double[][] time) { this.distance = distance; this.time = time; } @Override public double getTransportCost(Location from, Location to, double departureTime, Driver driver, Vehicle vehicle) { int fromIdx = Integer.parseInt(from.getId()); int toIdx = Integer.parseInt(to.getId()); return distance[fromIdx][toIdx]; } @Override public double getTransportTime(Location from, Location to, double departureTime, Driver driver, Vehicle vehicle) { int fromIdx = Integer.parseInt(from.getId()); int toIdx = Integer.parseInt(to.getId()); return time[fromIdx][toIdx]; } @Override public double getBackwardTransportCost(Location from, Location to, double arrivalTime, Driver driver, Vehicle vehicle) { return getTransportCost(from, to, arrivalTime, driver, vehicle); } @Override public double getBackwardTransportTime(Location from, Location to, double arrivalTime, Driver driver, Vehicle vehicle) { return getTransportTime(from, to, arrivalTime, driver, vehicle); } }这里最关键的坑是 Location 的 id 必须和 stops 下标一一对应。我用的是String.valueOf(index),所以 MatrixCosts 里Integer.parseInt能还原出矩阵下标。如果你采用业务 ID 作为 Location id,矩阵适配时还要额外做一层映射,容易漏。
getTransportCost 返回距离,算法会优先让总距离最小的方案胜出。getTransportTime 返回时间,时间窗约束会依赖它。如果你希望目标同时考虑时间和距离,可以在这里做加权,比如return distance[fromIdx][toIdx] + time[fromIdx][toIdx] * 系数,具体系数看业务里“时间”和“里程”哪个更值钱。
4.4 把优化结果转成可导航线路
求解器返回的是“哪辆车先去哪个客户点”的序列,不是能直接画在地图上的轨迹。所以最后一步是把每个车辆的途经点依次交给 RoutingService,调用 GraphHopper 生成完整的折线。
private VrpSolution decode(VehicleRoutingSolution solution, List<StopPosition> stops) { Map<Integer, StopPosition> indexMap = new HashMap<>(); for (int i = 0; i < stops.size(); i++) { indexMap.put(i, stops.get(i)); } List<VehicleRouteResult> routeResults = new ArrayList<>(); for (VehicleRoute route : solution.getRoutes()) { List<StopPosition> orderedStops = new ArrayList<>(); for (Job job : route.getTourActivities().getJobs()) { int index = Integer.parseInt(job.getId().replace("customer_", "")); orderedStops.add(indexMap.get(index)); } routeResults.add(new VehicleRouteResult(route.getVehicle().getId(), orderedStops)); } return new VrpSolution(routeResults); }如果你的业务需要地图轨迹,就在这一步之后对 orderedStops 每相邻两点调用一次 RoutingService.route,把 GHResponse 的 points 拼接起来。注意相邻点之间要处理首尾重复坐标,否则地图上会出现一个小毛刺。这个工作量不大,但很容易被忽略。
5. 多途径点规划最容易踩的坑:从矩阵到求解器的 5 个排查经验
5.1 启动报“GraphHopper cannot load graph”或者内存溢出
现象:SpringBoot 启动时 GraphHopper 初始化失败,日志里出现 OutOfMemoryError,或者提示 cache directory 不可用。
原因:第一次解析 PBF 时,JVM 默认堆内存不够用。尤其是全省或全国级别的 PBF,图数据构建阶段可能吃掉好几个 GB。还有两种情况是 cache-dir 权限不对,或者上一次导入中断留下半截缓存文件。
解决:启动参数加-Xmx8g甚至更高,具体看区域大小。如果一直用同一份 PBF,最好保留 cache-dir,不要每次删除。如果更新了 PBF,记得把旧的缓存目录删掉再启动,否则 GraphHopper 可能读旧图。
5.2 经纬度传反导致路线乱飞
现象:矩阵里的距离忽大忽小,路线画出来斜穿整个城市,或者 VRP 结果完全看不出规律。
原因:GraphHopper 的标准用法是 GHPoint(latitude, longitude),也就是纬度在前;而 JSprit 的 Coordinate 构造函数是 x、y,我传入时通常写 longitude、latitude。两个库的坐标顺序不一致,哪一侧写反都会出问题。
解决:在 dto 层统一用 latitude、longitude 命名。进入 GraphHopper 时先 lat 后 lon,进入 JSprit 时先 lon 后 lat。写完之后用一组距离很近的已知点做冒烟测试,比如两个相隔 100 米的点,矩阵结果应该在 0.1 公里左右。
5.3 求解结果把所有点分给一辆车,或者大量点显示 unassigned
现象:明明配了 5 辆车,结果只用了 1 辆;或者客户点没进任何路线,solution 里出现大量 unassigned jobs。
原因:最常见的是每辆车maxStops设置不对。如果 vehicle 的 capacity 维度没加,JSprit 默认容量无限大,当然全塞给一辆车。另一个原因是车辆没有设置 startLocation,导致算法认为车辆无法从任何地方出发。
解决:先检查 vehicle 的 capacity 是否设置了Dimensions.Tasks,再检查 startLocation 和 endLocation 都指向了 index 0。还需要确认每个 client job 都设置了addSizeDimension(Dimensions.Tasks, 1)。如果 point 数量大,还要提高setMaxIterations,迭代太少时算法可能找不到可行解。
5.4 优化结果看起来比人工还差
现象:总里程算出来反而比手工排单更长,或者司机路线出现明显回头路。
原因:我不建议直接怀疑算法,先怀疑矩阵。GraphHopper 的矩阵默认用的是 car profile,但如果地图数据只支持步行路线,或者 profile 名称写错,GraphHopper 会返回一条绕远的路。还有一种情况是 PBF 数据过期,某条路已经不能走,但图数据里还是通的。
解决:把矩阵结果导出来,随机抽几对点,逐个调用 /api/route/simple 对比。如果这两条路线的时间差异很大,说明矩阵和 route 使用了不同的 profile。如果一致但依旧绕路,就更新地图数据,或者换用更符合实际路况的 profile。
5.5 矩阵接口偶发超时,SpringBoot 频繁 503
现象:每次优化请求要算几百个点的矩阵,多个请求一起来就把服务拖垮。
原因:矩阵是 CPU 和内存密集型操作,在 Controller 线程里同步计算,后面的请求全在排队。虽然本地 GraphHopper 内部有并发能力,但 SpringBoot 默认线程池占用过高时依然会拒绝服务。
解决:把矩阵计算和 VRP 求解放到异步线程池,接口先返回任务 ID,客户端轮询结果。矩阵结果按“仓库+客户点集合”做短时间缓存,同一天的订单分布变化不大时,可以直接复用。另一个常用思路是把矩阵点拆成多个批次,超过 500 点就分批算。
6. 进阶验证:用固定样例做回归,而不是靠肉眼判断
6.1 固定测试数据和断言
多途径点规划最怕“这次看起来不错,下次改动不知道哪里坏了”。我后期所有改动都要回归同一组固定样例。比如四个订单点呈正方形分布,仓库在中心,最优解一定是从仓库出发按一个方向绕一圈,而不是来回交叉。
@Test void shouldNotProduceCrossingRoutes() { RoutePlanRequest request = RoutePlanRequestFactory.squareFourStops(); RoutePlanResult result = routePlanService.plan(request); for (VehicleRouteResult route : result.routes()) { double firstAngle = bearing(route.stops().get(0), route.stops().get(1)); double secondAngle = bearing(route.stops().get(1), route.stops().get(2)); assertTrue(Math.abs(firstAngle - secondAngle) < 90); } }这里的思路是固定输入,只验证结构特征。角度断言只是一个例子,更实用的断言是“总里程不能比人工最优解多 10%”。有了这个基线,改动 MatrixCosts 里的权重时,能立刻知道优化效果是变好还是变坏。
6.2 两个生产习惯:矩阵缓存和异步化
我先说结论:多途径点规划模块不适合做成同步接口。VRP 求解时间随点数是近似指数上升的,同步接口迟早会拖垮调用方。我会把整个 plan 操作丢进 @Async 线程池,执行完后把结果写入内存缓存或数据库。
矩阵缓存还有个细节:同一辆车的起点和客户点经常变化,但两两之间的 GraphHopper 矩阵成本在一天内几乎不变。我会把 costMatrix 缓存在带过期时间的 Map 里,过期时间通常设 30 分钟。如果你的业务路况变化很敏感,可以缩短到 10 分钟。
回到开头那个模拟项目X,我最后悔的是开始没有把“矩阵结果”和“VRP 结果”分开验证,导致改了一次地图数据后,整个优化结果静默变差,我还以为是算法参数问题。后来把固定样例回归和矩阵抽样对比加进测试流程,再改任何路网或权重参数,都能在十分钟内判断是否值得上线。希望这个方案能让你少走这段弯路。
本文还有配套的精品资源,点击获取