用这4招,优雅的实现Spring Boot 异步线程间数据传递

Spring Boot 自定义线程池可以实现异步开发,在实际开发中需要在父子线程之间传递一些数据,比如用户信息,链路信息等等

比如用户登录信息使用ThreadLocal存放保证线程隔离,代码如下:

/** * @description 用户上下文信息 */ public class OauthContext { private static final ThreadLocal<LoginVal> loginValThreadLocal=new ThreadLocal<>(); public static LoginVal get(){ return loginValThreadLocal.get(); } public static void set(LoginVal loginVal){ loginValThreadLocal.set(loginVal); } public static void clear(){ loginValThreadLocal.remove(); } }

那么子线程想要获取这个LoginVal如何做呢?

今天就来介绍几种优雅的方式实现Spring Boot 内部的父子线程的数据传递。

1. 手动设置

每执行一次异步线程都要分为两步:

  1. 获取父线程的LoginVal

  2. 将LoginVal设置到子线程,达到复用

代码如下:

public void handlerAsync() { //1. 获取父线程的loginVal LoginVal loginVal = OauthContext.get(); log.info("父线程的值:{}",OauthContext.get()); CompletableFuture.runAsync(()->{ //2. 设置子线程的值,复用 OauthContext.set(loginVal); log.info("子线程的值:{}",OauthContext.get()); }); }

虽然能够实现目的,但是每次开异步线程都需要手动设置,重复代码太多,看了头疼,你认为优雅吗?

2. 线程池设置TaskDecorator

TaskDecorator是什么?官方api的大致意思:这是一个执行回调方法的装饰器,主要应用于传递上下文,或者提供任务的监控/统计信息。

知道有这么一个东西,如何去使用?

TaskDecorator是一个接口,首先需要去实现它,代码如下:

/** * @description 上下文装饰器 */ public class ContextTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { //获取父线程的loginVal LoginVal loginVal = OauthContext.get(); return () -> { try { // 将主线程的请求信息,设置到子线程中 OauthContext.set(loginVal); // 执行子线程,这一步不要忘了 runnable.run(); } finally { // 线程结束,清空这些信息,否则可能造成内存泄漏 OauthContext.clear(); } }; } }

这里我只是设置了LoginVal,实际开发中其他的共享数据,比如SecurityContextRequestAttributes....

TaskDecorator需要结合线程池使用,实际开发中异步线程建议使用线程池,只需要在对应的线程池配置一下,代码如下:

@Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor poolTaskExecutor = new ThreadPoolTaskExecutor(); poolTaskExecutor.setCorePoolSize(xx); poolTaskExecutor.setMaxPoolSize(xx); // 设置线程活跃时间(秒) poolTaskExecutor.setKeepAliveSeconds(xx); // 设置队列容量 poolTaskExecutor.setQueueCapacity(xx); //设置TaskDecorator,用于解决父子线程间的数据复用 poolTaskExecutor.setTaskDecorator(new ContextTaskDecorator()); poolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 等待所有任务结束后再关闭线程池 poolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true); return poolTaskExecutor; }

此时业务代码就不需要去设置子线程的值,直接使用即可,代码如下:

public void handlerAsync() { log.info("父线程的用户信息:{}", OauthContext.get()); //执行异步任务,需要指定的线程池 CompletableFuture.runAsync(()-> log.info("子线程的用户信息:{}", OauthContext.get()),taskExecutor); }

来看一下结果,如下图:

这里使用的是CompletableFuture执行异步任务,使用@Async这个注解同样是可行的。

注意:无论使用何种方式,都需要指定线程池

3. InheritableThreadLocal

这种方案不建议使用,InheritableThreadLocal虽然能够实现父子线程间的复用,但是在线程池中使用会存在复用的问题。

这种方案使用也是非常简单,直接用InheritableThreadLocal替换ThreadLocal即可,代码如下:

/** * @description 用户上下文信息 */ public class OauthContext { private static final InheritableThreadLocal<LoginVal> loginValThreadLocal=new InheritableThreadLocal<>(); public static LoginVal get(){ return loginValThreadLocal.get(); } public static void set(LoginVal loginVal){ loginValThreadLocal.set(loginVal); } public static void clear(){ loginValThreadLocal.remove(); } }

4. TransmittableThreadLocal

TransmittableThreadLocal是阿里开源的工具,弥补了InheritableThreadLocal的缺陷,在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。

使用起来也是非常简单,添加依赖如下:

<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.14.2</version> </dependency>

OauthContext改造代码如下:

/** * @description 用户上下文信息 */ public class OauthContext { private static final TransmittableThreadLocal<LoginVal> loginValThreadLocal=new TransmittableThreadLocal<>(); public static LoginVal get(){ return loginValThreadLocal.get(); } public static void set(LoginVal loginVal){ loginValThreadLocal.set(loginVal); } public static void clear(){ loginValThreadLocal.remove(); } }

TransmittableThreadLocal原理

从定义来看,TransimittableThreadLocal继承于InheritableThreadLocal,并实现TtlCopier接口,它里面只有一个copy方法。所以主要是对InheritableThreadLocal的扩展。

public class TransmittableThreadLocal<T> extends InheritableThreadLocal<T> implements TtlCopier<T>

TransimittableThreadLocal中添加holder属性。这个属性的作用就是被标记为具备线程传递资格的对象都会被添加到这个对象中。

要标记一个类,比较容易想到的方式,就是给这个类新增一个Type字段,还有一个方法就是将具备这种类型的的对象都添加到一个静态全局集合中。之后使用时,这个集合里的所有值都具备这个标记。

// 1. holder本身是一个InheritableThreadLocal对象 // 2. 这个holder对象的value是WeakHashMap<TransmittableThreadLocal<Object>, ?> // 2.1 WeekHashMap的value总是null,且不可能被使用。 // 2.2 WeekHasshMap支持value=null private static InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>> holder = new InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>>() { @Override protected WeakHashMap<TransmittableThreadLocal<Object>, ?> initialValue() { return new WeakHashMap<TransmittableThreadLocal<Object>, Object>(); } /** * 重写了childValue方法,实现上直接将父线程的属性作为子线程的本地变量对象。 */ @Override protected WeakHashMap<TransmittableThreadLocal<Object>, ?> childValue(WeakHashMap<TransmittableThreadLocal<Object>, ?> parentValue) { return new WeakHashMap<TransmittableThreadLocal<Object>, Object>(parentValue); } };

应用代码是通过TtlExecutors工具类对线程池对象进行包装。工具类只是简单的判断,输入的线程池是否已经被包装过、非空校验等,然后返回包装类ExecutorServiceTtlWrapper。根据不同的线程池类型,有不同和的包装类。

@Nullable public static ExecutorService getTtlExecutorService(@Nullable ExecutorService executorService) { if (TtlAgent.isTtlAgentLoaded() || executorService == null || executorService instanceof TtlEnhanced) { return executorService; } return new ExecutorServiceTtlWrapper(executorService); }

进入包装类ExecutorServiceTtlWrapper。可以注意到不论是通过ExecutorServiceTtlWrapper#submit方法或者是ExecutorTtlWrapper#execute方法,都会将线程对象包装成TtlCallable或者TtlRunnable,用于在真正执行run方法前做一些业务逻辑。

/** * 在ExecutorServiceTtlWrapper实现submit方法 */ @NonNull @Override public <T> Future<T> submit(@NonNull Callable<T> task) { return executorService.submit(TtlCallable.get(task)); } /** * 在ExecutorTtlWrapper实现execute方法 */ @Override public void execute(@NonNull Runnable command) { executor.execute(TtlRunnable.get(command)); }

所以,重点的核心逻辑应该是在TtlCallable#call()或者TtlRunnable#run()中。以下以TtlCallable为例,TtlRunnable同理类似。在分析call()方法之前,先看一个类Transmitter

public static class Transmitter { /** * 捕获当前线程中的是所有TransimittableThreadLocal和注册ThreadLocal的值。 */ @NonNull public static Object capture() { return new Snapshot(captureTtlValues(), captureThreadLocalValues()); } /** * 捕获TransimittableThreadLocal的值,将holder中的所有值都添加到HashMap后返回。 */ private static HashMap<TransmittableThreadLocal<Object>, Object> captureTtlValues() { HashMap<TransmittableThreadLocal<Object>, Object> ttl2Value = new HashMap<TransmittableThreadLocal<Object>, Object>(); for (TransmittableThreadLocal<Object> threadLocal : holder.get().keySet()) { ttl2Value.put(threadLocal, threadLocal.copyValue()); } return ttl2Value; } /** * 捕获注册的ThreadLocal的值,也就是原本线程中的ThreadLocal,可以注册到TTL中,在 * 进行线程池本地变量传递时也会被传递。 */ private static HashMap<ThreadLocal<Object>, Object> captureThreadLocalValues() { final HashMap<ThreadLocal<Object>, Object> threadLocal2Value = new HashMap<ThreadLocal<Object>, Object>(); for(Map.Entry<ThreadLocal<Object>,TtlCopier<Object>>entry:threadLocalHolder.entrySet()){ final ThreadLocal<Object> threadLocal = entry.getKey(); final TtlCopier<Object> copier = entry.getValue(); threadLocal2Value.put(threadLocal, copier.copy(threadLocal.get())); } return threadLocal2Value; } /** * 将捕获到的本地变量进行替换子线程的本地变量,并且返回子线程现有的本地变量副本backup。 * 用于在执行run/call方法之后,将本地变量副本恢复。 */ @NonNull public static Object replay(@NonNull Object captured) { final Snapshot capturedSnapshot = (Snapshot) captured; return new Snapshot(replayTtlValues(capturedSnapshot.ttl2Value), replayThreadLocalValues(capturedSnapshot.threadLocal2Value)); } /** * 替换TransmittableThreadLocal */ @NonNull private static HashMap<TransmittableThreadLocal<Object>, Object> replayTtlValues(@NonNull HashMap<TransmittableThreadLocal<Object>, Object> captured) { // 创建副本backup HashMap<TransmittableThreadLocal<Object>, Object> backup = new HashMap<TransmittableThreadLocal<Object>, Object>(); for (final Iterator<TransmittableThreadLocal<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) { TransmittableThreadLocal<Object> threadLocal = iterator.next(); // 对当前线程的本地变量进行副本拷贝 backup.put(threadLocal, threadLocal.get()); // 若出现调用线程中不存在某个线程变量,而线程池中线程有,则删除线程池中对应的本地变量 if (!captured.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); } } // 将捕获的TTL值打入线程池获取到的线程TTL中。 setTtlValuesTo(captured); // 是一个扩展点,调用TTL的beforeExecute方法。默认实现为空 doExecuteCallback(true); return backup; } private static HashMap<ThreadLocal<Object>, Object> replayThreadLocalValues(@NonNull HashMap<ThreadLocal<Object>, Object> captured) { final HashMap<ThreadLocal<Object>, Object> backup = new HashMap<ThreadLocal<Object>, Object>(); for (Map.Entry<ThreadLocal<Object>, Object> entry : captured.entrySet()) { final ThreadLocal<Object> threadLocal = entry.getKey(); backup.put(threadLocal, threadLocal.get()); final Object value = entry.getValue(); if (value == threadLocalClearMark) threadLocal.remove(); else threadLocal.set(value); } return backup; } /** * 清除单线线程的所有TTL和TL,并返回清除之气的backup */ @NonNull public static Object clear() { final HashMap<TransmittableThreadLocal<Object>, Object> ttl2Value = new HashMap<TransmittableThreadLocal<Object>, Object>(); final HashMap<ThreadLocal<Object>, Object> threadLocal2Value = new HashMap<ThreadLocal<Object>, Object>(); for(Map.Entry<ThreadLocal<Object>,TtlCopier<Object>>entry:threadLocalHolder.entrySet()){ final ThreadLocal<Object> threadLocal = entry.getKey(); threadLocal2Value.put(threadLocal, threadLocalClearMark); } return replay(new Snapshot(ttl2Value, threadLocal2Value)); } /** * 还原 */ public static void restore(@NonNull Object backup) { final Snapshot backupSnapshot = (Snapshot) backup; restoreTtlValues(backupSnapshot.ttl2Value); restoreThreadLocalValues(backupSnapshot.threadLocal2Value); } private static void restoreTtlValues(@NonNull HashMap<TransmittableThreadLocal<Object>, Object> backup) { // 扩展点,调用TTL的afterExecute doExecuteCallback(false); for (final Iterator<TransmittableThreadLocal<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) { TransmittableThreadLocal<Object> threadLocal = iterator.next(); if (!backup.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); } } // 将本地变量恢复成备份版本 setTtlValuesTo(backup); } private static void setTtlValuesTo(@NonNull HashMap<TransmittableThreadLocal<Object>, Object> ttlValues) { for (Map.Entry<TransmittableThreadLocal<Object>, Object> entry : ttlValues.entrySet()) { TransmittableThreadLocal<Object> threadLocal = entry.getKey(); threadLocal.set(entry.getValue()); } } private static void restoreThreadLocalValues(@NonNull HashMap<ThreadLocal<Object>, Object> backup) { for (Map.Entry<ThreadLocal<Object>, Object> entry : backup.entrySet()) { final ThreadLocal<Object> threadLocal = entry.getKey(); threadLocal.set(entry.getValue()); } } /** * 快照类,保存TTL和TL */ private static class Snapshot { final HashMap<TransmittableThreadLocal<Object>, Object> ttl2Value; final HashMap<ThreadLocal<Object>, Object> threadLocal2Value; private Snapshot(HashMap<TransmittableThreadLocal<Object>, Object> ttl2Value, HashMap<ThreadLocal<Object>, Object> threadLocal2Value) { this.ttl2Value = ttl2Value; this.threadLocal2Value = threadLocal2Value; } }

进入TtlCallable#call()方法。

@Override public V call() throws Exception { Object captured = capturedRef.get(); if (captured == null || releaseTtlValueReferenceAfterCall && !capturedRef.compareAndSet(captured, null)) { throw new IllegalStateException("TTL value reference is released after call!"); } // 调用replay方法将捕获到的当前线程的本地变量,传递给线程池线程的本地变量, // 并且获取到线程池线程覆盖之前的本地变量副本。 Object backup = replay(captured); try { // 线程方法调用 return callable.call(); } finally { // 使用副本进行恢复。 restore(backup); } }

到这基本上线程池方式传递本地变量的核心代码已经大概看完了。总的来说在创建TtlCallable对象是,调用capture()方法捕获调用方的本地线程变量,在call()执行时,将捕获到的线程变量,替换到线程池所对应获取到的线程的本地变量中,并且在执行完成之后,将其本地变量恢复到调用之前。

总结

上述列举了4种方案,这里推荐方案2和方案4,其中两种方案的缺点非常明显,实际开发中也是采用的方案2或者方案4。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/1131484.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

深度学习毕设项目:基于CNN深度学习python的遥感图片识别沙漠湖泊和森林

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

【课程设计/毕业设计】基于python机器学习的猫狗表情识别

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

【建议收藏】AI 音乐提示词终极指南|全网最全的创作控制手册|Suno 进阶指南|第 15 篇

历史文章 Suno AI API接入 - 将AI音乐接入到自己的产品中&#xff0c;支持120并发任务 Suno用邓紫棋的声音唱《我不是真正的快乐》 | 进阶指南 | 第8篇 Suno 12 轨全轨分离导出midi伴奏分离实战&#xff5c;进阶指南&#xff5c;第 11 篇 续写卡在 2 秒&#xff1f;解决方案…

链表相加(二)

代码求解 public ListNode reverseList(ListNode pHead){if(pHead null){return null;}ListNode pre null;ListNode cur pHead;ListNode next pHead;while(cur!null){next cur.next;cur.next pre;pre cur;cur next;}return pre;}public ListNode addInList (ListNode h…

从一道前端面试题,谈 JS 对象存储特点和运算符执行顺序

本文大纲今天来看一道前端面试的代码输出题。 面试官提供了一段 Javascript 代码&#xff0c;要求给出这段代码运行后的输出结果。 const obj {a: 0, };obj[1] 0; obj[obj.a] obj.a; const values Object.values(obj); obj[values[1]] obj.a; console.log(obj);先分析这道…

在RabbitMQ中,怎么确保消息不会丢失?

为了确保消息不会丢失&#xff0c;可以从以下3个方面解决&#xff1a; 1.在创建队列的时候设置durable为true&#xff0c;发布消息的时候设置delivery为2&#xff0c;从而确保队列和消息都是持久的。 这样&#xff0c;就算是RabbitMQ服务器重启也不会造成消息的丢失。 2.开启发…

深度学习毕设项目:基于python的印刷体数字和字母识别基于python深度学习的印刷体数字和字母识别

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

大数据领域Zookeeper的集群配置自动化工具推荐

大数据基石的"自动建筑师"&#xff1a;Zookeeper集群配置自动化工具深度解析 关键词 Zookeeper集群、配置自动化、Ansible、Kubernetes Operator、云原生运维、分布式协调、故障容错 摘要 在大数据生态中&#xff0c;Zookeeper作为分布式系统的"协调大脑"&…

【交通标示识别】模板匹配雾霾交通标示识别【含GUI Matlab源码 14873期】

&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;Matlab武动乾坤博客之家&#x1f49e;…

PO、VO、BO、DTO、DAO、POJO有什么区别?

PO&#xff08;Persistent Object&#xff09;持久化对象&#xff0c;主要用于和数据库交互&#xff0c;是数据库数据在内存中的镜像。 VO&#xff08;View Object&#xff09;视图对象&#xff0c;和前端展示强相关&#xff0c;按需组装前端需要的字段。 BO&#xff08;Busine…

MYSQL_安装与配置(超详细,仅需一篇就能帮你成功安装MYSQL)

上一篇博客相信大家都已经了解到博主要讲那个数据库了&#xff0c;跟着博主的步伐一起来配置和安装数据库吧&#xff01;&#xff01;&#xff01; **1.**使用Installer安装 1.1 下载最新版本Installer 官下载8.0.X的最新版本 &#xff0c;址&#xff1a;https://dev.mysql.…

ArcGIS Pro3.5.2安装包+安装详细教程+系统需求

文章将包括系统安装需求说明软件包分享安装教程 先前版本请看&#xff1a; ArcGIS Pro稳定版 3.0.2 破解安装软件分享测试说明 ArcGIS Pro 3.1.5 破解安装软件分享测试说明 ArcGIS Pro3.3.2安装详细教程旧版本直接升级还是卸载旧版本再装&#xff1f; 一、系统安装需求升级…

【课程设计/毕业设计】基于 python的CNN深度学习的遥感图片识别沙漠湖泊和森林

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

MySQL进程CPU 飙升900%,领导让我查什么原因?

说一下问题&#xff1a;CPU飙升200% 以上是生产容易发生的场景场景:1&#xff1a;MySQL进程飙升900%大家在使用MySQL过程&#xff0c;想必都有遇到过CPU突然过高&#xff0c;或者达到200%以上的情况。数据库执行查询或数据修改操作时&#xff0c;系统需要消耗大量的CPU资源维护…

ArcGIS Pro查看多期数据变化!卷帘+多地图联动齐上架

之前介绍了ArcGIS效果工具条实现查看两期数据变化。 那么&#xff0c;如果在ArcGIS Pro中又该如何操作呢&#xff1f;ArcGIS Pro中其实有对应的卷帘效果与比较功能&#xff01;一样可以把卷帘和透明度用起来。 多期数据源的加载查看&#xff1a; 2025年最新&#xff08;全球&a…

Stream流式编程 中间操作和终端操作介绍

一、先立心智模型&#xff1a;Stream 到底是什么&#xff1f; Stream 不是集合&#xff0c;不存数据&#xff0c;不改数据。 它是&#xff1a; 对数据源的一次性、惰性的、函数式“处理流程描述” 三点必须刻在脑子里&#xff1a; 惰性执行&#xff1a;中间操作不执行&#x…

【苹果分级】基于matlab GUI机器视觉苹果质量检测及总分级系统【含Matlab源码 14878期】

&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49e;欢迎来到海神之光博客之家&#x1f49e;&#x1f49e;&#x1f49e;&#x1f49…

【课程设计/毕业设计】基于深度学习的印刷体数字和字母识别基于python深度学习的印刷体数字和字母识别

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

深度学习计算机毕设之机器学习基于CNN深度学习的遥感图片识别沙漠湖泊和森林

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

ArcGIS土地利用现状图制作全流程

将之前《ArcGIS Pro从0到1制作广东省土地利用现状图》直播回重新录制一遍给大家学习。详见下方视频教学 主要涉及的内容包括&#xff1a;ArcGIS土地利用现状图全流程教学&#xff08;从零数据下载、镶嵌、裁剪、属性表连接整理、面积区域汇总计算、地图全流程制作&#xff09; …