tomcat的Session周期和常用的缓存失效机制

前言

session的失效机制实际上是一种LRU过期淘汰机制,在缓存系统设计中有很广泛的应用,比如LinkedHashMap的失效机制,redis的值失效机制以及guava的cache失效机制。本文希望做一个简单的对比,为在以后的类似程序设计中有更好的参考。

1. tomcat的启动方法

本文使用的tomcat代码是springboot-2.2.6.RELEASE版本内嵌的tomcat-embed-core-9.0.33版本。
如果熟悉tomcat的同学应该知道,tomcat的启动/关闭的类是Catalina,外置的tomcat的启动类Bootstrap实际是对Catalina做的一些参数化的组合。

//main方法供startup脚本调用
  public static void main(String args[]) {

        synchronized (daemonLock) {
            if (daemon == null) {
                // Don't set daemon until init() has completed
                Bootstrap bootstrap = new Bootstrap();
                try {
                    bootstrap.init();//这里是初始化Catalina类
                } catch (Throwable t) {
                    handleThrowable(t);
                    t.printStackTrace();
                    return;
                }
                daemon = bootstrap;
            } else {
                // When running as a service the call to stop will be on a new
                // thread so make sure the correct class loader is used to
                // prevent a range of class not found exceptions.
                Thread.currentThread().setContextClassLoader(daemon.catalinaLoader);
            }
        }
       try {
            String command = "start";
            if (args.length > 0) {
                command = args[args.length - 1];
            }
            if (command.equals("startd")) {
                args[args.length - 1] = "start";
                daemon.load(args);
                daemon.start();//该方法里调用Catalina的start方法
            } else if (command.equals("stopd")) {
               ...省略
    }        

init方法

    public void init() throws Exception {
        initClassLoaders();
        Thread.currentThread().setContextClassLoader(catalinaLoader);
        SecurityClassLoad.securityClassLoad(catalinaLoader);
        // Load our startup class and call its process() method
        if (log.isDebugEnabled())
            log.debug("Loading startup class");
        Class<?> startupClass = catalinaLoader.loadClass("org.apache.catalina.startup.Catalina");//反射获取Catalina类
        Object startupInstance = startupClass.getConstructor().newInstance();
        // Set the shared extensions class loader
        if (log.isDebugEnabled())
            log.debug("Setting startup class properties");
        String methodName = "setParentClassLoader";
        Class<?> paramTypes[] = new Class[1];
        paramTypes[0] = Class.forName("java.lang.ClassLoader");
        Object paramValues[] = new Object[1];
        paramValues[0] = sharedLoader;
        Method method =
            startupInstance.getClass().getMethod(methodName, paramTypes);
        method.invoke(startupInstance, paramValues);//设置classload的上下文
        catalinaDaemon = startupInstance;
    }

start() 方法

    public void start() throws Exception {
        if (catalinaDaemon == null) {
            init();
        }
        Method method = catalinaDaemon.getClass().getMethod("start", (Class [])null);//反射调用catalina的start方法初始化server实例
        method.invoke(catalinaDaemon, (Object [])null);
    }

内嵌的tomcat则提供了Tomcat类,有init()方法和start()方法,SpringBoot在启动时,会构造ServletWebServerFactoryTomcatServletWebServerFactory是其实现,用来配置相关信息,生产TomcatWebServer(WebServer 的实现),

	public WebServer getWebServer(ServletContextInitializer... initializers) {
		if (this.disableMBeanRegistry) {
			Registry.disableRegistry();
		}
		Tomcat tomcat = new Tomcat();
		File baseDir = (this.baseDirectory != null) ? this.baseDirectory : createTempDir("tomcat");
		tomcat.setBaseDir(baseDir.getAbsolutePath());
		Connector connector = new Connector(this.protocol);//默认是rg.apache.coyote.http11.Http11NioProtocol协议,用的是NIO
		connector.setThrowOnFailure(true);
		tomcat.getService().addConnector(connector);
		customizeConnector(connector);
		tomcat.setConnector(connector);
		tomcat.getHost().setAutoDeploy(false);
		configureEngine(tomcat.getEngine());
		for (Connector additionalConnector : this.additionalTomcatConnectors) {
			tomcat.getService().addConnector(additionalConnector);
		}
		prepareContext(tomcat.getHost(), initializers);
		return getTomcatWebServer(tomcat);
	}

getTomcatWebServer方法,

	protected TomcatWebServer getTomcatWebServer(Tomcat tomcat) {
		return new TomcatWebServer(tomcat, getPort() >= 0);
	}

TomcatWebServer的构造器里的initialize()方法去初始化,

private void initialize() throws WebServerException {
		logger.info("Tomcat initialized with port(s): " + getPortsDescription(false));
		synchronized (this.monitor) {
			try {
				addInstanceIdToEngineName();

				Context context = findContext();
				context.addLifecycleListener((event) -> {
					if (context.equals(event.getSource()) && Lifecycle.START_EVENT.equals(event.getType())) {
						// Remove service connectors so that protocol binding doesn't
						// happen when the service is started.
						removeServiceConnectors();
					}
				});

				// Start the server to trigger initialization listeners
				this.tomcat.start();//启动内嵌的tomcat

				// We can re-throw failure exception directly in the main thread
				rethrowDeferredStartupExceptions();

				try {
					ContextBindings.bindClassLoader(context, context.getNamingToken(), getClass().getClassLoader());
				}
				catch (NamingException ex) {
					// Naming is not enabled. Continue
				}
				// Unlike Jetty, all Tomcat threads are daemon threads. We create a
				// blocking non-daemon to stop immediate shutdown
				startDaemonAwaitThread();
			}
			catch (Exception ex) {
				stopSilently();
				destroySilently();
				throw new WebServerException("Unable to start embedded Tomcat", ex);
			}
		}
	}

如果查看tomcat类的的start()方法和Catalina类的start()方法,本质上一样的,都是启动Server服务器,这里其实tomcat类也可以直接去调到Catalina类方法,但是内嵌的tomcat类没有这么做,估计是Catalina类会读取配置文件的一系列参数,而这些参数在内嵌的tomcat是没有的,所以内嵌的tomcat类直接初始化Server服务器了。

2. ManagerBase类

ManagerBase类是Manager的实现,是管理session的具体实现,管理session的生命周期,比如createSession,remove,expireSession等方法。
sessions是用ConcurrentHashMap的数据结构存储的,支持并发处理。

    /**
     * The set of currently active Sessions for this Manager, keyed by
     * session identifier.
     */
    protected Map<String, Session> sessions = new ConcurrentHashMap<>();

backgroundProcess方法用来处理过期的session,调用的是rocessExpires() 方法。

    @Override
    public void backgroundProcess() {
        count = (count + 1) % processExpiresFrequency;
        if (count == 0)
            processExpires();
    }

processExpiresFrequency变量用来控制检查过期session的频率,默认processExpiresFrequency=6,该值可以设置。

   public void processExpires() {

        long timeNow = System.currentTimeMillis();
        Session sessions[] = findSessions();//这里将sessions复制成数组,而不是直接操作ConcurrentHashMap,应该是减少对ConcurrentHashMap并发影响。
        int expireHere = 0 ;

        if(log.isDebugEnabled())
            log.debug("Start expire sessions " + getName() + " at " + timeNow + " sessioncount " + sessions.length);
        for (int i = 0; i < sessions.length; i++) {
            if (sessions[i]!=null && !sessions[i].isValid()) {
                expireHere++;//记录总共执行了多少次的过期处理以及单次检查的耗时。
            }
        }
        long timeEnd = System.currentTimeMillis();
        if(log.isDebugEnabled())
             log.debug("End expire sessions " + getName() + " processingTime " + (timeEnd - timeNow) + " expired sessions: " + expireHere);
        processingTime += ( timeEnd - timeNow );
    }

可以看到处理过期事件是交由seesion.isVaild()去处理的,具体实现在StandardSession

    @Override
    public boolean isValid() {

        if (!this.isValid) {//检查是否已经失效了,volatile类型
            return false;
        }
        if (this.expiring) {//检查是否正在处理过期中,volatile类型
            return true;
        }
        if (ACTIVITY_CHECK && accessCount.get() > 0) {//ACTIVITY_CHECK是可以设置的值,默认是flase,如果是true的话,需要检查accessCount的次数,该值用来计数该session是否还在被访问,一般结束访问时调用endAccess()方法自减。
            return true;
        }
        if (maxInactiveInterval > 0) {
            int timeIdle = (int) (getIdleTimeInternal() / 1000L);
            if (timeIdle >= maxInactiveInterval) {//检查最后的访问时间是否大于闲时可存活时间,执行过期操作
                expire(true);
            }
        }
        return this.isValid;
    }

实际上就是检查一些状态是否可以执行过期操作。

public void expire(boolean notify) {

        // Check to see if session has already been invalidated.
        // Do not check expiring at this point as expire should not return until
        // isValid is false
        if (!isValid)//再次检查是否有效的
            return;

        synchronized (this) {//加锁,防止并发,因为seesion的expire()方法是可以主动被调用的。
            // Check again, now we are inside the sync so this code only runs once
            // Double check locking - isValid needs to be volatile
            // The check of expiring is to ensure that an infinite loop is not
            // entered as per bug 56339
            if (expiring || !isValid)//检查是否正在执行过期动作和检查是否有效
                return;

            if (manager == null)
                return;

            // Mark this session as "being expired"
            expiring = true;//设置正在处理中

            // Notify interested application event listeners
            // FIXME - Assumes we call listeners in reverse order
            Context context = manager.getContext();

            // The call to expire() may not have been triggered by the webapp.
            // Make sure the webapp's class loader is set when calling the
            // listeners
            if (notify) {//下面是一系列的事件通知
                ClassLoader oldContextClassLoader = null;
                try {
                    oldContextClassLoader = context.bind(Globals.IS_SECURITY_ENABLED, null);
                    Object listeners[] = context.getApplicationLifecycleListeners();
                    if (listeners != null && listeners.length > 0) {
                        HttpSessionEvent event =
                            new HttpSessionEvent(getSession());
                        for (int i = 0; i < listeners.length; i++) {
                            int j = (listeners.length - 1) - i;
                            if (!(listeners[j] instanceof HttpSessionListener))
                                continue;
                            HttpSessionListener listener =
                                (HttpSessionListener) listeners[j];
                            try {
                                context.fireContainerEvent("beforeSessionDestroyed",
                                        listener);
                                listener.sessionDestroyed(event);
                                context.fireContainerEvent("afterSessionDestroyed",
                                        listener);
                            } catch (Throwable t) {
                                ExceptionUtils.handleThrowable(t);
                                try {
                                    context.fireContainerEvent(
                                            "afterSessionDestroyed", listener);
                                } catch (Exception e) {
                                    // Ignore
                                }
                                manager.getContext().getLogger().error
                                    (sm.getString("standardSession.sessionEvent"), t);
                            }
                        }
                    }
                } finally {
                    context.unbind(Globals.IS_SECURITY_ENABLED, oldContextClassLoader);
                }
            }
            if (ACTIVITY_CHECK) {
                accessCount.set(0);//设置为0,当前访问已结束
            }
            // Remove this session from our manager's active sessions
            manager.remove(this, true);//这里是调用manger的remove方法,因为manger里持有了sessions的map对象。

            // Notify interested session event listeners
            if (notify) {
                fireSessionEvent(Session.SESSION_DESTROYED_EVENT, null);
            }

            // Call the logout method
            if (principal instanceof TomcatPrincipal) {
                TomcatPrincipal gp = (TomcatPrincipal) principal;
                try {
                    gp.logout();
                } catch (Exception e) {
                    manager.getContext().getLogger().error(
                            sm.getString("standardSession.logoutfail"),
                            e);
                }
            }
            // We have completed expire of this session
            setValid(false);//设置无效
            expiring = false;//处理完毕

            // Unbind any objects associated with this session
            String keys[] = keys();
            ClassLoader oldContextClassLoader = null;
            try {
                oldContextClassLoader = context.bind(Globals.IS_SECURITY_ENABLED, null);
                for (int i = 0; i < keys.length; i++) {
                    removeAttributeInternal(keys[i], notify);//移除该session里的所有属性
                }
            } finally {
                context.unbind(Globals.IS_SECURITY_ENABLED, oldContextClassLoader);//解绑该session的一些关联
            }
        }
    }

从上面的方法里可以看出,session的有效性检查里包含了一系列的操作,包括一些状态的检查,并发性控制,属性的移除,事件的通知,但是session的生命周期理论上是由Manager管理的,所以还需要去回调manager.remove方法,要实现这种,就需要session和manager相互持有对方的引用,不且说这样的方式实现是否优雅,需要注意JVM的垃圾回收机制是否能够有效的回收掉。
回到ManagerBase类中,

    public void remove(Session session, boolean update) {
        // If the session has expired - as opposed to just being removed from
        // the manager because it is being persisted - update the expired stats
        if (update) {
            long timeNow = System.currentTimeMillis();
            int timeAlive =
                (int) (timeNow - session.getCreationTimeInternal())/1000;
            updateSessionMaxAliveTime(timeAlive);
            expiredSessions.incrementAndGet();
            SessionTiming timing = new SessionTiming(timeNow, timeAlive);
            synchronized (sessionExpirationTiming) {//sessionExpirationTiming是用来统计过期的比例的,填充了100个空值,这个设计也是有特色,可以用并发的队列来实现,不用加synchronized
                sessionExpirationTiming.add(timing);
                sessionExpirationTiming.poll();
            }
        }

        if (session.getIdInternal() != null) {//从map里remove
            sessions.remove(session.getIdInternal());
        }
    }

3. ContainerBackgroundProcessorMonitor和ContainerBackgroundProcessor

ManagerBase类提供了backgroundProcess()方法用来处理过期的session,ManagerBase本身并没有使用定时调度或者在插入或者删除session操作的时候去调用backgroundProcess()方法,实际的调用者是ContainerBackgroundProcessor类。

protected class ContainerBackgroundProcessor implements Runnable {

        @Override
        public void run() {
            processChildren(ContainerBase.this);
        }

        protected void processChildren(Container container) {
            ClassLoader originalClassLoader = null;

            try {
                if (container instanceof Context) {
                    Loader loader = ((Context) container).getLoader();
                    // Loader will be null for FailedContext instances
                    if (loader == null) {
                        return;
                    }

                    // Ensure background processing for Contexts and Wrappers
                    // is performed under the web app's class loader
                    originalClassLoader = ((Context) container).bind(false, null);
                }
                container.backgroundProcess();//处理方法
                Container[] children = container.findChildren();
                for (int i = 0; i < children.length; i++) {
                    if (children[i].getBackgroundProcessorDelay() <= 0) {
                        processChildren(children[i]);//递归调用子类
                    }
                }
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
                log.error(sm.getString("containerBase.backgroundProcess.error"), t);
            } finally {
                if (container instanceof Context) {
                    ((Context) container).unbind(false, originalClassLoader);
                }
            }
        }
    }

这里要理解tomcat里的Container的父子级关系。
在这里插入图片描述

  • Engine,包含Host和Context,接到请求后仍给相应的Host在相应的Context里处理
  • Host,虚拟主机
  • Context,具体Web应用的上下文,每个请求都在是相应的上下文里处理
  • Wrapper,管理servlet生命周期

真正有效的container.backgroundProcess()的方法是

 @Override
    public void backgroundProcess() {

        if (!getState().isAvailable())
            return;

        Loader loader = getLoader();
        if (loader != null) {
            try {
                loader.backgroundProcess();
            } catch (Exception e) {
                log.warn(sm.getString(
                        "standardContext.backgroundProcess.loader", loader), e);
            }
        }
        Manager manager = getManager();
        if (manager != null) {
            try {
                manager.backgroundProcess();//调用ManagerBase的backgroundProcess方法
            } catch (Exception e) {
                log.warn(sm.getString(
                        "standardContext.backgroundProcess.manager", manager),
                        e);
            }
        }
        WebResourceRoot resources = getResources();
        if (resources != null) {
            try {
                resources.backgroundProcess();
            } catch (Exception e) {
                log.warn(sm.getString(
                        "standardContext.backgroundProcess.resources",
                        resources), e);
            }
        }
        InstanceManager instanceManager = getInstanceManager();
        if (instanceManager != null) {
            try {
                instanceManager.backgroundProcess();
            } catch (Exception e) {
                log.warn(sm.getString(
                        "standardContext.backgroundProcess.instanceManager",
                        resources), e);
            }
        }
        super.backgroundProcess();
    }

ContainerBackgroundProcessor实现了Runnable方法,被线程池定期执行,定期检查session的超时。

    /**
     * Start the background thread that will periodically check for
     * session timeouts.
     */
   protected void threadStart() {
   //注意这里的条件,首先状态是STARTING_PREP,就是预开始,而且backgroundProcessorFuture==null,也就是说只有在第一次预启动的时候才会初始化定时的调度器,理解这里的意思会对上一步的调用者很有帮助
        if (backgroundProcessorDelay > 0
                && (getState().isAvailable() || LifecycleState.STARTING_PREP.equals(getState()))
                && (backgroundProcessorFuture == null || backgroundProcessorFuture.isDone())) {
            if (backgroundProcessorFuture != null && backgroundProcessorFuture.isDone()) {
                // There was an error executing the scheduled task, get it and log it
                try {
                    backgroundProcessorFuture.get();
                } catch (InterruptedException | ExecutionException e) {
                    log.error(sm.getString("containerBase.backgroundProcess.error"), e);
                }
            }
            //这里构建了一个延迟10S每10S执行的调度器
            backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
                    .scheduleWithFixedDelay(new ContainerBackgroundProcessor(),
                            backgroundProcessorDelay, backgroundProcessorDelay,
                            TimeUnit.SECONDS);
        }
    }

这里比较吊诡的是threadStart()方法又是由ContainerBackgroundProcessorMonitor类里的run方法调用的,
ContainerBackgroundProcessorMonitor的调用者,ContainerBase的startInternal()方法,

protected synchronized void startInternal() throws LifecycleException {

        // Start our subordinate components, if any
        logger = null;
        getLogger();
        Cluster cluster = getClusterInternal();
        if (cluster instanceof Lifecycle) {
            ((Lifecycle) cluster).start();
        }
        Realm realm = getRealmInternal();
        if (realm instanceof Lifecycle) {
            ((Lifecycle) realm).start();
        }

        // Start our child containers, if any
        Container children[] = findChildren();
        List<Future<Void>> results = new ArrayList<>();
        for (int i = 0; i < children.length; i++) {
            results.add(startStopExecutor.submit(new StartChild(children[i])));
        }

        MultiThrowable multiThrowable = null;

        for (Future<Void> result : results) {
            try {
                result.get();
            } catch (Throwable e) {
                log.error(sm.getString("containerBase.threadedStartFailed"), e);
                if (multiThrowable == null) {
                    multiThrowable = new MultiThrowable();
                }
                multiThrowable.add(e);
            }

        }
        if (multiThrowable != null) {
            throw new LifecycleException(sm.getString("containerBase.threadedStartFailed"),
                    multiThrowable.getThrowable());
        }

        // Start the Valves in our pipeline (including the basic), if any
        if (pipeline instanceof Lifecycle) {
            ((Lifecycle) pipeline).start();
        }

        setState(LifecycleState.STARTING);

        // Start our thread
        //这里又启动了一个定时的调度器
        if (backgroundProcessorDelay > 0) {
            monitorFuture = Container.getService(ContainerBase.this).getServer()
                    .getUtilityExecutor().scheduleWithFixedDelay(
                            new ContainerBackgroundProcessorMonitor(), 0, 60, TimeUnit.SECONDS);
        }
    }

可以看出,如果threadStart() 方法里不做限制,就会出现一个定时的调度器里的任务又启动了一个定时调度器,这样几次下来之后,会出现任务无穷多直到把JVM内存耗光。
这里的tomcat的设计有点不太懂,ContainerBackgroundProcessorMonitor的调度器只有第一次的调用会有效,其他的大部分的调度都是浪费的,可能的理由是这里是防止后面的ContainerBackgroundProcessor启动失败,做的重试机制,但是启动成功了就可以结束最外层的调度的,有点资源浪费。还好,Server::getUtilityExecutor都是一个实现,而且里面只有一个work线程。

总结下来,ManagerBase提供了session的失效机制方法供给容器的定时调度器去检查。

4. LinkedHashMap过期机制

LinkedHashMap提供了removeEldestEntry(Map.Entry<K,V> eldest)方法,该方法的返回值默认为false。

    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

该方法为protected方法,需要子类去继承和改写。
调用方法为:

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

从方法名可以看出是在插入的时候,检查过期策略来删除队列里顶端元素,LinkHashMap内部实现了一个队列且是有序的。
下面的实例构造一个了简单的自定义的CustomLinkedHashMap,继承和重写了removeEldestEntry方法,当size大于3的时候,删除队顶元素。

public static void main(String[] args) {
		LinkedHashMap<String,String> map = new CustomLinkedHashMap<>();
		map.put("111", "aaa");
		map.put("222", "bbb");
		map.put("333", "ccc");
		map.put("444", "ddd");
		map.put("555", "eee");
		
		map.forEach((k,v) -> {System.out.println(k + " : " + v);} );
	}

	
	static class CustomLinkedHashMap<K,V> extends LinkedHashMap<K, V>{
		private static final long serialVersionUID = -1512329794026226223L;

		@Override
	    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
			if(super.size() > 3) {
				return true;
			}
	        return false;
	    }
	}

执行后的结果为:

333 : ccc
444 : ddd
555 : eee

总结:LinkHashMap的过期机制本质上HashMap预留的模板方法,需要子类去实现,而且只能删除队顶元素,功能较弱,当然也可以在
removeEldestEntry方法里遍历整个map里的元素,判断过期机制来删除其他元素,但是这样的实现不符合父类留给的子类的模板方法,可能会有意想不到的BUG。

5. Guava中的LocalCache的过期机制

在构建Cache对象时,可以通过CacheBuilder类的expireAfterAccess和expireAfterWrite两个方法为缓存中的对象指定过期时间,使用CacheBuilder构建的缓存不会“自动”执行清理和逐出值,也不会在值到期后立即执行或逐出任何类型。相反,它在写入操作期间执行少量维护,或者在写入很少的情况下偶尔执行读取操作。其中,expireAfterWrite方法指定对象被写入到缓存后多久过期,expireAfterAccess指定对象多久没有被访问后过期。主要在get和put的时候检查是否过期,内部分别用两个队列来存储元素AccessQueue和WriteQueue队列,get的时候会向accessQueue中记录,put的时候会同时向accessQueue和WriteQueue队列中记录。
Guava Cache与java1.7的ConcurrentMap的设计类似,采用了segement的分组桶设计,segement继承了ReentrantLock,用锁机制不会有并发冲突,accessQueue和WriteQueue队列都是属于segement当中的全局变量。
执行过期清理的方法

    void cleanUp() {
      long now = map.ticker.read();
      runLockedCleanup(now);//检查过期失效机制并删除元素
      runUnlockedCleanup();//通知事件
    }
    void expireEntries(long now) {
      drainRecencyQueue();//收集弱引用被GC的元素
      ReferenceEntry<K, V> e;
      while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {//检查WriteQueue队列
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
          throw new AssertionError();
        }
      }
      while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {//检查AccessQueue队列
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
          throw new AssertionError();
        }
      }
    }

Guava Cache也提供了方法供外部调用清除过期数据,cleanUp() 和clear()方法,还提供了RemovalListener监听接口用来监听事件。
总结:Guava Cache的过期机制较为全面,既有在插入和查找的时候执行LRU策略,也提供了外部的“自动清理”的接口。

最后总结

从Tomcat的session过期机制分析了常用的缓存过期机制,比如LinkedHashMap过期机制和Guava Cache,一般常用的过期机制无外乎在增删改查的时候检查过期,还有提供外部的“自动清理”机制,后者更需要关注线程安全问题。

参考资料

  1. https://www.cnblogs.com/aspirant/p/11734918.html
  2. 深入Tomcat(中文版)
  3. https://www.jianshu.com/p/2839d05ab42e
  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值