xxl-job分析

xxl-job是一个分布式调度平台,具体的介绍请看官方文档👉️https://www.xuxueli.com/xxl-job/
你可以简单的理解为它是Quartz的兄弟,做的事情差不多,在项目中用作定时任务的框架。

分析源码前,先看一下官网给出的架构图,这里分析的是2.0.1版本的xxl-job,各个版本的架构有略微的区别,需要注意。
在这里插入图片描述
从架构中看出,其实就两大模块, 调度中心和执行器。

看一下后端架构
在这里插入图片描述
🌵xxl-job-admin模块对应一个admin,也就是管理台或者称为调度中心,管理台可以设置成多个模块,使用不同的端口,但是这样没有意义,一般项目中就一个admin模块。
🌵xxl-job-core是公共的核心模块。
🌵xxl-job-executor-samples模块下存放的是各个执行器模块,每个执行器模块可以看成是一个单独的服务,执行器是绑定到管理台模块下的,通过在配置文件中的xxl.job.admin.addresses属性。
我们随便进入到一个执行器模块中,看一下任务是如何写的
在这里插入图片描述
通过@XxlJob就定义了一个任务,并默认绑定到了该执行器中,执行器已启动,解析@XxlJob修饰的方法后,就封装到了jobHandlerRepository这个map中了。
所以,调度器(管理台)、执行器和任务的关系是:一个调度器绑定多个执行器,一个执行器又绑定了多个任务。
执行器和任务一对多的关系,所以,在数据库中,在多的一方保存单一方的id,如下任务表中:
在这里插入图片描述
job_group字段就是该任务对应的执行器id。
上述主要讲述了调度器、执行器和任务的关系。

接下来xxl-job-core模块

在这里插入图片描述
我们需要知道,AdminBiz和ExecutorBiz是代理对象,将请求转发给调度器和执行器,控制对调度器和执行器的访问。

同时,执行器初始化后的线程都是单例的,看代码

public class ExecutorRegistryThread {
    private static Logger logger = LoggerFactory.getLogger(ExecutorRegistryThread.class);

    private static ExecutorRegistryThread instance = new ExecutorRegistryThread();
    public static ExecutorRegistryThread getInstance(){
        return instance;
    }

单例要满足的条件:
🌵构造函数私有化
🌵定义一个静态的变量来存储实例对象
🌵定义一个静态的get方法来获取实例对象

由于特殊性,上面没有定义构造函数也是可以的,因为调用到该类的只有初始化时的XxlJobExecutor类。
到这里core模块就介绍完了。

接下来介绍一下xxl-job-admin

如下图
在这里插入图片描述
我们需要知道,它和执行器通信是通过代理对象ExecutorBiz和Controller类JobApiController完成的。ExecutorBiz用来主动向执行器发起通讯,JobApiController用来接收执行器发送的请求。
XxlJobScheduler是调度器的初始化类。

以上是通过架构图分析了整个系统的大致流程。
下面我们来分析某些具体的问题
🌵被@XxlJob修饰的方法是如何被解析的?
🌵执行器是怎么被注册的?
🌵调度器和执行器的心跳机制是什么样的?
🌵通过在管理台手动触发任务后是怎么找到对应执行器的任务并执行的?
🌵执行器执行完任务后是怎么和调度器沟通的?怎么回调的?
🌵父子任务是怎么协调执行的?
🌵分片广播的执行机制是怎样的?
🌵内置的RPC调度模块是怎么设计的?

下面就带着问题来看看源码

一、被@XxlJob修饰的方法是如何被解析的?

解析分为两步:
🌵获取到所有的xxlJob
🌵注册xxlJob

//这个方法用来获取所有的xxlJob和被xxlJob修饰的方法
private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {
        if (applicationContext == null) {
            return;
        }
        // 从spring容器中获取所有的被注册的对象
        String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
        //依次遍历这些对象,将xxlJob和对应的方法解析出来一一对应存放到annotatedMethods变量中
        for (String beanDefinitionName : beanDefinitionNames) {
            Object bean = applicationContext.getBean(beanDefinitionName);

            Map<Method, XxlJob> annotatedMethods = null; 
            try {
                annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),
                        new MethodIntrospector.MetadataLookup<XxlJob>() {
                            @Override
                            public XxlJob inspect(Method method) {
                                return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                            }
                        });
            } catch (Throwable ex) {
                logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);
            }
            if (annotatedMethods==null || annotatedMethods.isEmpty()) {
                continue;
            }
			//进行注册
            for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
                Method executeMethod = methodXxlJobEntry.getKey();
                XxlJob xxlJob = methodXxlJobEntry.getValue();
                // regist
                registJobHandler(xxlJob, bean, executeMethod);
            }
        }
    }

下面是注册

protected void registJobHandler(XxlJob xxlJob, Object bean, Method executeMethod){
        if (xxlJob == null) {
            return;
        }
        //拿到@XxlJob中的value值
        String name = xxlJob.value();
       //bean是当前XxlJob所在的对象
        Class<?> clazz = bean.getClass();
        //executeMethod是被@XxlJob修饰的方法名
        String methodName = executeMethod.getName();
        if (name.trim().length() == 0) {
            throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + clazz + "#" + methodName + "] .");  
        }
        if (loadJobHandler(name) != null) {
            throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");
        }
        //利用反射的setAccessible,设置该方法可以访问操作,也就是可以被调用
        executeMethod.setAccessible(true);

        // init and destroy
        Method initMethod = null;
        Method destroyMethod = null;

        if (xxlJob.init().trim().length() > 0) {
            try {
                initMethod = clazz.getDeclaredMethod(xxlJob.init());
                initMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }
        if (xxlJob.destroy().trim().length() > 0) {
            try {
                destroyMethod = clazz.getDeclaredMethod(xxlJob.destroy());
                destroyMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }

        // 注册,name是@XxlJob的value值,执行的方法被MethodJobHandler封装
        registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));
    }
private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();
public static IJobHandler registJobHandler(String name, IJobHandler jobHandler){
        logger.info(">>>>>>>>>>> xxl-job register jobhandler success, name:{}, jobHandler:{}", name, jobHandler);
        return jobHandlerRepository.put(name, jobHandler);
    }

所谓的注册,其实就是存放到了是map类型的类变量中保存了。这个map的key是@XxlJob的value值,value是用MethodJobHandler封装的被@XxlJob修饰的方法。
总结:大致流程是:执行器启动-》初始化单例的XxlJobSpringExecutor-》在XxlJobSpringExecutor初始化的过程中解析XxlJob

二、执行器是怎么被注册的?

注册也分为两步:
🌵执行器启动后执行ExecutorRegistryThread线程调用调度器来注册
🌵调度器获取到请求并进行注册,操作xxl-job-registry

public class ExecutorRegistryThread {
   ........
    public void start(final String appname, final String address){
       .........
        registryThread = new Thread(new Runnable() {
            @Override
            public void run() {
                // registry
                while (!toStop) {
                    try {
                        RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                            try {
                                ReturnT<String> registryResult = adminBiz.registry(registryParam);
                                ..........

执行到这里,调用了管理台代理对象adminBiz,使用管理台来注册

public class AdminBizClient implements AdminBiz {
........
    @Override
    public ReturnT<String> registry(RegistryParam registryParam) {
        return XxlJobRemotingUtil.postBody(addressUrl + "api/registry", accessToken, timeout, registryParam, String.class);
    }
........

AdminBizClient 是真正的管理台代理对象了,adminBiz是代理接口。通过AdminBizClient代理对象控制对管理台的访问。这里将注册的逻辑交给了管理台。

我们来到管理台控制层,这这里接收了执行器的请求

public class JobApiController {
    @Resource
    private AdminBiz adminBiz;
    
    @RequestMapping("/{uri}")
    @ResponseBody
    @PermissionLimit(limit=false)
    public ReturnT<String> api(HttpServletRequest request, @PathVariable("uri") String uri, @RequestBody(required = false) String data) {
    .......
        // services mapping
        if ("callback".equals(uri)) {
            List<HandleCallbackParam> callbackParamList = GsonTool.fromJson(data, List.class, HandleCallbackParam.class);
            return adminBiz.callback(callbackParamList);
        } else if ("registry".equals(uri)) {
            RegistryParam registryParam = GsonTool.fromJson(data, RegistryParam.class);
            return adminBiz.registry(registryParam);
        } else if ("registryRemove".equals(uri)) {
            RegistryParam registryParam = GsonTool.fromJson(data, RegistryParam.class);
            return adminBiz.registryRemove(registryParam);
        } else {
            return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping("+ uri +") not found.");
        }
    }

然后交给了AdminBizImpl

@Service
public class AdminBizImpl implements AdminBiz {
    @Override
    public ReturnT<String> callback(List<HandleCallbackParam> callbackParamList) {
        return JobCompleteHelper.getInstance().callback(callbackParamList);
    }
    @Override
    public ReturnT<String> registry(RegistryParam registryParam) {
        return JobRegistryHelper.getInstance().registry(registryParam);
    }
    @Override
    public ReturnT<String> registryRemove(RegistryParam registryParam) {
        return JobRegistryHelper.getInstance().registryRemove(registryParam);
    }
}

AdminBizImpl 交给了JobRegistryHelper线程单例对象

public ReturnT<String> registry(RegistryParam registryParam) {

		// valid
		if (!StringUtils.hasText(registryParam.getRegistryGroup())
				|| !StringUtils.hasText(registryParam.getRegistryKey())
				|| !StringUtils.hasText(registryParam.getRegistryValue())) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, "Illegal Argument.");
		}

		// async execute
		registryOrRemoveThreadPool.execute(new Runnable() {
			@Override
			public void run() {
				int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
				if (ret < 1) {
					XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());

					// fresh
					freshGroupRegistryInfo(registryParam);
				}
			}
		});

		return ReturnT.SUCCESS;
	}

该方法便是最终的执行注册的方法了。我们需要注意几点:
🌵执行注册是使用线程池异步调用的
🌵逻辑也很简单,就是先试着更新一下,如果不成功,则表示没有该记录,然后在新增,也就是在xxl-job-registry新增一条记录。

三、调度器和执行器的心跳机制是什么样的?

官网是这么说的:执行器注册: 任务注册Beat周期默认30s; 执行器以一倍Beat进行执行器注册, 调度中心以一倍Beat进行动态任务发现; 注册信息的失效时间为三倍Beat;
我们看一下配置类

public class RegistryConfig {
    public static final int BEAT_TIMEOUT = 30;
    public static final int DEAD_TIMEOUT = BEAT_TIMEOUT * 3;

    public enum RegistType{ EXECUTOR, ADMIN }
}

我们看一下执行器的失效代码,具体逻辑在JobRegistryHelper中的registryMonitorThread线程单例

List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
							if (ids!=null && ids.size()>0) {
								XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
							}

我们看一下findDead方法的Sql语句

<select id="findDead" parameterType="java.util.HashMap" resultType="java.lang.Integer" >
		SELECT t.id
		FROM xxl_job_registry AS t
		WHERE t.update_time <![CDATA[ < ]]> DATE_ADD(#{nowTime},INTERVAL -#{timeout} SECOND)
	</select>

逻辑也很简单,就是在Sql中进行时间的相减拿到超时的执行器。

执行器进行beat是在ExecutorRegistryThread中,和注册是用的同一个线程。官方说的是一倍beat,其实只是大致的时间,将死亡时间设置为3倍beat,是一个合适值,太小,可能等不到执行器beat就被pass了,太大,可能执行器真的挂掉了,而前台业务正在调用这个执行器导致超时。

四、通过在管理台手动触发任务后是怎么找到对应执行器的任务并执行的?

首先,我们需要知道,每个任务被执行,都会创建一个JobThread对象并放到jobThreadRepository变量中,然后将调度任务push到TriggerQueue队列中。
在哪里监控这个TriggerQueue队列并处理任务的呢?
还是在JobThread中,这个对象中有一个轮询的start方法,会判断triggerQueue中的值然后遍历执行任务。
下面截取关键代码

//这里是ExecutorBizImpl类中执行任务调度的run方法
if (jobThread == null) {
            jobThread = XxlJobExecutor.registJobThread(triggerParam.getJobId(), jobHandler, removeOldReason);
        }

        // push data to queue
        ReturnT<String> pushResult = jobThread.pushTriggerQueue(triggerParam);
        return pushResult;

该方法将调度任务存放到了调度队列中。
下面是JobThread线程轮询处理调度队列

while(!toStop){
			running = false;
			idleTimes++;
            TriggerParam triggerParam = null;
            try {
				// to check toStop signal, we need cycle, so wo cannot use queue.take(), instand of poll(timeout)
				triggerParam = triggerQueue.poll(3L, TimeUnit.SECONDS);

每次执行完一个任务后会将调度结果push到回调队列callBackQueue中

public static void pushCallBack(HandleCallbackParam callback){
        getInstance().callBackQueue.add(callback);
        logger.debug(">>>>>>>>>>> xxl-job, push callback request, logId:{}", callback.getLogId());
    }

然后会有专门的回调线程轮询处理回调。

五、执行器执行完任务后是怎么和调度器沟通的?怎么回调的?

接着第四点,我们来看看回调的过程。
执行完成后将结果push到了callBackQueue中,然后回调线程轮询回调
下面截取关键代码

//在TriggerCallbackThread回调线程中
 while(!toStop){
                    try {
                        HandleCallbackParam callback = getInstance().callBackQueue.take();
                        if (callback != null) {

                            // callback list param
                            List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();
                            int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);
                            callbackParamList.add(callback);

                            // callback, will retry if error
                            if (callbackParamList!=null && callbackParamList.size()>0) {
                                doCallback(callbackParamList);
                            }

private void doCallback(List<HandleCallbackParam> callbackParamList){
        boolean callbackRet = false;
        // callback, will retry if error
        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
            try {
                ReturnT<String> callbackResult = adminBiz.callback(callbackParamList);

回调doCallback方法调用了代理对象adminBiz的callback方法,然后调度器的controller接收到请求并处理回调。

六、父子任务是怎么协调执行的?

接着第五点,当执行器处理完任务后回调结果,调度器的回调线程轮询处理结果
关键代码如下

//JobCompleteHelper类中
private ReturnT<String> callback(HandleCallbackParam handleCallbackParam) {
		........
		// handle msg
		StringBuffer handleMsg = new StringBuffer();
		if (log.getHandleMsg()!=null) {
			handleMsg.append(log.getHandleMsg()).append("<br>");
		}
		if (handleCallbackParam.getHandleMsg() != null) {
			handleMsg.append(handleCallbackParam.getHandleMsg());
		}
		.........
		XxlJobCompleter.updateHandleInfoAndFinish(log);
		return ReturnT.SUCCESS;
	}

继续看updateHandleInfoAndFinish方法,是在XxlJobCompleter类中

public static int updateHandleInfoAndFinish(XxlJobLog xxlJobLog) {

        // finish
        finishJob(xxlJobLog);
        // text最大64kb 避免长度过长
        if (xxlJobLog.getHandleMsg().length() > 15000) {
            xxlJobLog.setHandleMsg( xxlJobLog.getHandleMsg().substring(0, 15000) );
        }
        // fresh handle
        return XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateHandleInfo(xxlJobLog);
    }

private static void finishJob(XxlJobLog xxlJobLog){
       ..........
            XxlJobInfo xxlJobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(xxlJobLog.getJobId());
            if (xxlJobInfo!=null && xxlJobInfo.getChildJobId()!=null && xxlJobInfo.getChildJobId().trim().length()>0) {
                triggerChildMsg = "<br><br><span style=\"color:#00c0ef;\" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_child_run") +"<<<<<<<<<<< </span><br>";

                String[] childJobIds = xxlJobInfo.getChildJobId().split(",");
                for (int i = 0; i < childJobIds.length; i++) {
                    int childJobId = (childJobIds[i]!=null && childJobIds[i].trim().length()>0 && isNumeric(childJobIds[i]))?Integer.valueOf(childJobIds[i]):-1;
                    if (childJobId > 0) {

                        JobTriggerPoolHelper.trigger(childJobId, TriggerTypeEnum.PARENT, -1, null, null, null);
                        ReturnT<String> triggerChildResult = ReturnT.SUCCESS;

上面的finishJob方法就是继续完成子任务
总结:大致流程就是,当在调度器中接收到执行器的回调请求后,回调线程会处理任务的日志并检查该任务是否还有子任务,如有,则调用JobTriggerPoolHelper线程去调用任务。

七、分片广播的执行机制是怎样的?

分片广播属于任务的一个配置项,也就是路由策略,属于xxl-job中比较重要的路由策略。执行逻辑还是在调度线程的类XxlJobTrigger中。
看一下XxlJobTrigger中的关键代码

// sharding param
        int[] shardingParam = null;
        if (executorShardingParam!=null){
            String[] shardingArr = executorShardingParam.split("/");
            if (shardingArr.length==2 && isNumeric(shardingArr[0]) && isNumeric(shardingArr[1])) {
                shardingParam = new int[2];
                shardingParam[0] = Integer.valueOf(shardingArr[0]);
                shardingParam[1] = Integer.valueOf(shardingArr[1]);
            }
        }
        if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST==ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
                && group.getRegistryList()!=null && !group.getRegistryList().isEmpty()
                && shardingParam==null) {
            for (int i = 0; i < group.getRegistryList().size(); i++) {
                processTrigger(group, jobInfo, finalFailRetryCount, triggerType, i, group.getRegistryList().size());
            }
        } else {
            if (shardingParam == null) {
                shardingParam = new int[]{0, 1};
            }
            processTrigger(group, jobInfo, finalFailRetryCount, triggerType, shardingParam[0], shardingParam[1]);
        }

我们需要知道的是:
🌵xxl-job提供给了我们两个分片的参数:index和total。index表示当前执行器在for循环中的下标,total表示地址集合大小。
🌵将路由策略分为了两大部分逻辑,一是分片,二是其他的路由策略。
🌵具体的分片由我们自定义,我们可以利用这index和total参数来做分片业务,例如在数据库中可以利用取余来处理数据记录:

SELECT id,name,password
FROM t_push
WHERE `status` = 0
AND mod (id,#{number}) = #{index}  //number 分片总数,index 当前分片数
order by id desc
LIMIT 100;

🌵关键的就是那个for循环,体现了广播的特性,又巧妙的将每次循环的下标当作分片的index进行传参。

八、内置的RPC调度模块是怎么设计的?

巧妙的利用了代理对象、NIO实现的服务器配合传统的Connection和url建立连接请求,配合springmvc实现的。
具体来看一下内置的服务器

thread = new Thread(new Runnable() {
            @Override
            public void run() {
                // param
                EventLoopGroup bossGroup = new NioEventLoopGroup();
                EventLoopGroup workerGroup = new NioEventLoopGroup();
                ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(
                        0,
                        200,
                        60L,
                        TimeUnit.SECONDS,
                        new LinkedBlockingQueue<Runnable>(2000),
                        new ThreadFactory() {
                            @Override
                            public Thread newThread(Runnable r) {
                                return new Thread(r, "xxl-job, EmbedServer bizThreadPool-" + r.hashCode());
                            }
                        },
                        new RejectedExecutionHandler() {
                            @Override
                            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                                throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
                            }
                        });
                try {
                    // start server
                    ServerBootstrap bootstrap = new ServerBootstrap();
                    bootstrap.group(bossGroup, workerGroup)
                            .channel(NioServerSocketChannel.class)
                            .childHandler(new ChannelInitializer<SocketChannel>() {
                                @Override
                                public void initChannel(SocketChannel channel) throws Exception {
                                    channel.pipeline()
                                            .addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS))  // beat 3N, close if idle
                                            .addLast(new HttpServerCodec())
                                            .addLast(new HttpObjectAggregator(5 * 1024 * 1024))  // merge request & reponse to FULL
                                            .addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));
                                }
                            })
                            .childOption(ChannelOption.SO_KEEPALIVE, true);

                    // bind
                    ChannelFuture future = bootstrap.bind(port).sync();

                    logger.info(">>>>>>>>>>> xxl-job remoting server start success, nettype = {}, port = {}", EmbedServer.class, port);

                    // start registry
                    startRegistry(appname, address);

                    // wait util stop
                    future.channel().closeFuture().sync();

                } catch (InterruptedException e) {
                    logger.info(">>>>>>>>>>> xxl-job remoting server stop.");
                } catch (Exception e) {
                    logger.error(">>>>>>>>>>> xxl-job remoting server error.", e);
                } finally {
                    // stop
                    try {
                        workerGroup.shutdownGracefully();
                        bossGroup.shutdownGracefully();
                    } catch (Exception e) {
                        logger.error(e.getMessage(), e);
                    }
                }
            }
        });
        thread.setDaemon(true);    // daemon, service jvm, user thread leave >>> daemon leave >>> jvm leave
        thread.start();
    }

上面是一个很经典的NIO异步非阻塞线程,使用了bossGroup和workerGroup来分别做数据的读取和操作。

总结:
🌵使用了代理模式,单例模式等经典的设计模式,很好的解耦了调度器和执行器。
🌵使用了线程池和NIO,异步处理任务和回调,性能优越。
🌵大部分的业务逻辑交给了单例线程执行,进一步解耦,也使得系统清晰明了。

世界地图矢量数据可以通过多种网站进行下载。以下是一些提供免费下载世界地图矢量数据的网站: 1. Open Street Map (https://www.openstreetmap.org/): 这个网站可以根据输入的经纬度或手动选定范围来导出目标区域的矢量图。导出的数据格式为osm格式,但只支持矩形范围的地图下载。 2. Geofabrik (http://download.geofabrik.de/): Geofabrik提供按洲际和国家快速下载全国范围的地图数据。数据格式支持shape文件格式,包含多个独立图层,如道路、建筑、水域、交通、土地利用分类、自然景观等。数据每天更新一次。 3. bbbike (https://download.bbbike.org/osm/): bbbike提供全球主要的200多个城市的地图数据下载,也可以按照bbox进行下载。该网站还提供全球的数据,数据格式种类齐全,包括geojson、shp等。 4. GADM (https://gadm.org/index.html): GADM提供按国家或全球下载地图数据的服务。该网站提供多种格式的数据下载。 5. L7 AntV (https://l7.antv.antgroup.com/custom/tools/worldmap): L7 AntV是一个提供标准世界地图矢量数据免费下载的网站。支持多种数据格式下载,包括GeoJSON、KML、JSON、TopJSON、CSV和高清SVG格式等。可以下载中国省、市、县的矢量边界和世界各个国家的矢量边界数据。 以上这些网站都提供了世界地图矢量数据的免费下载服务,你可以根据自己的需求选择合适的网站进行下载。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值