Netty的ServerBootStrap启动过程源码的解析(二)

4 篇文章 0 订阅

        在前面一篇文章中我们介绍了启动Netty的服务端的EventLoopGroup的创建过程以及源码的跟踪,我们了解了它是怎么新建工作线程及入口线程的,接下来我们就看看基础线程建立好以后,我们是如何一步步的启动服务的,我们还是把代码贴一次

public static void main(String[] args) {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            NettyServerHandlerTest1 handlerTest1 = new NettyServerHandlerTest1();
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup,workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG,1024)
                    .handler(new LoggingHandler(LogLevel.INFO))
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            ChannelPipeline pipeline = socketChannel.pipeline();
                            pipeline.addLast(handlerTest1);
                        }
                    });
            ChannelFuture channelFuture = serverBootstrap.bind(8010).sync();
            channelFuture.channel().closeFuture().sync();
        }catch (Exception e) {
            e.printStackTrace();
        }finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

      在上面代码中服务的启动是通过新建一个ServerBootstrap对象作为启动对象的,对象创建完成后我们把bossGroup和workGroup传递给了它,并且使用的是NioServerSocketChannel通道,传递了一个option参数,同时给serverBootstrap添加了一个自定义的handler对象,当然这些只是一些初始化操作,并没有启动服务,真正启动服务的是bind()方法的执行,我们下面就接着看下bind()方法的执行过程

通过serverBootstrap.bind()方法我们经过一些传递方法进入到AbstractBootstrap的doBind(final SocketAddress localAddress)方法中,其实现如下

private ChannelFuture doBind(final SocketAddress localAddress) {
        // 初始化channel中的信息
        final ChannelFuture regFuture = initAndRegister();
        // 获取初始化完成后channel信息
        final Channel channel = regFuture.channel();
        if (regFuture.cause() != null) {
            return regFuture;
        }

        // 判断是否已经完成初始化
        if (regFuture.isDone()) {
            // At this point we know that the registration was complete and successful.
            ChannelPromise promise = channel.newPromise();
            // 使用户自定义的handler能够有效
            doBind0(regFuture, channel, localAddress, promise);
            return promise;
        } else {
            // Registration future is almost always fulfilled already, but just in case it's not.
            final PendingRegistrationPromise promise = new PendingRegistrationPromise(channel);
            regFuture.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) throws Exception {
                    Throwable cause = future.cause();
                    if (cause != null) {
                        // Registration on the EventLoop failed so fail the ChannelPromise directly to not cause an
                        // IllegalStateException once we try to access the EventLoop of the Channel.
                        promise.setFailure(cause);
                    } else {
                        // Registration was successful, so set the correct executor to use.
                        // See https://github.com/netty/netty/issues/2586
                        promise.registered();

                        doBind0(regFuture, channel, localAddress, promise);
                    }
                }
            });
            return promise;
        }
    }

    final ChannelFuture initAndRegister() {
        Channel channel = null;
        try {
            // 通过反射的方式创建一个channel
            channel = channelFactory.newChannel();
            // 初始化channel信息
            init(channel);
        } catch (Throwable t) {
            if (channel != null) {
                // channel can be null if newChannel crashed (eg SocketException("too many open files"))
                channel.unsafe().closeForcibly();
                // as the Channel is not registered yet we need to force the usage of the GlobalEventExecutor
                return new DefaultChannelPromise(channel, GlobalEventExecutor.INSTANCE).setFailure(t);
            }
            // as the Channel is not registered yet we need to force the usage of the GlobalEventExecutor
            return new DefaultChannelPromise(new FailedChannel(), GlobalEventExecutor.INSTANCE).setFailure(t);
        }

        // 通过Java原生的nio进行注册启动初始化工作
        ChannelFuture regFuture = config().group().register(channel);
        if (regFuture.cause() != null) {
            if (channel.isRegistered()) {
                channel.close();
            } else {
                channel.unsafe().closeForcibly();
            }
        }

        // If we are here and the promise is not failed, it's one of the following cases:
        // 1) If we attempted registration from the event loop, the registration has been completed at this point.
        //    i.e. It's safe to attempt bind() or connect() now because the channel has been registered.
        // 2) If we attempted registration from the other thread, the registration request has been successfully
        //    added to the event loop's task queue for later execution.
        //    i.e. It's safe to attempt bind() or connect() now:
        //         because bind() or connect() will be executed *after* the scheduled registration task is executed
        //         because register(), bind(), and connect() are all bound to the same thread.

        return regFuture;
    }

       我们看下在initAndRegister()方法中初始化Channel,通过init(channel)方法进去可以看到它是一个抽象方法,它的具体实现分别在ServerBootstrap和Bootstrap中,这里因为看的是ServerBootstrap类,所以我们直接看下它的实现

void init(Channel channel) throws Exception {
        // 获取初始化ServerBootstrap时定义的option()方法中的数据
        final Map<ChannelOption<?>, Object> options = options0();
        synchronized (options) {
            setChannelOptions(channel, options, logger);
        }
          

         // 获取初始化ServerBootstrap时定义的attr()方法中的数据
        final Map<AttributeKey<?>, Object> attrs = attrs0();
        synchronized (attrs) {
            for (Entry<AttributeKey<?>, Object> e: attrs.entrySet()) {
                @SuppressWarnings("unchecked")
                AttributeKey<Object> key = (AttributeKey<Object>) e.getKey();
                channel.attr(key).set(e.getValue());
            }
        }

        // 获取channel中的一个通道
        ChannelPipeline p = channel.pipeline();

        final EventLoopGroup currentChildGroup = childGroup;
        final ChannelHandler currentChildHandler = childHandler;
        final Entry<ChannelOption<?>, Object>[] currentChildOptions;
        final Entry<AttributeKey<?>, Object>[] currentChildAttrs;
        synchronized (childOptions) {
            currentChildOptions = childOptions.entrySet().toArray(newOptionArray(0));
        }
        synchronized (childAttrs) {
            currentChildAttrs = childAttrs.entrySet().toArray(newAttrArray(0));
        }
        
        p.addLast(new ChannelInitializer<Channel>() {
            @Override
            public void initChannel(final Channel ch) throws Exception {
                final ChannelPipeline pipeline = ch.pipeline();
                ChannelHandler handler = config.handler();
                if (handler != null) {
                    pipeline.addLast(handler);
                }

                // 添加类似reactor线程模型当中的Acceptor
                ch.eventLoop().execute(new Runnable() {
                    @Override
                    public void run() {
                        pipeline.addLast(new ServerBootstrapAcceptor(
                                ch, currentChildGroup, currentChildHandler, currentChildOptions, currentChildAttrs));
                    }
                });
            }
        });
    }

       在init()方法中我们主要将一些初始化的信息添加到当前channel中,并对一些公共变量赋值,并且在责任链头部添加了一个类似reactor线程模型的Acceptor对象。初始化完成后回到AbstractBootstrap的initAndRegister()方法的ChannelFuture regFuture = config().group().register(channel)处,我们先看下进入到register方法后的执行顺序图

       通过上面的执行图我们可以看到经过一些执行顺序我们直接进入到AbstractChannel类的register(EventLoop eventLoop, final ChannelPromise promise)方法

public final void register(EventLoop eventLoop, final ChannelPromise promise) {
            if (eventLoop == null) {
                throw new NullPointerException("eventLoop");
            }
            // 判断是否已经注册过
            if (isRegistered()) {
                promise.setFailure(new IllegalStateException("registered to an event loop already"));
                return;
            }

             // 判断实例类型
            if (!isCompatible(eventLoop)) {
                promise.setFailure(
                        new IllegalStateException("incompatible event loop type: " + eventLoop.getClass().getName()));
                return;
            }

            AbstractChannel.this.eventLoop = eventLoop;

            // 判断当前线程是否是已经在运行的线程
            if (eventLoop.inEventLoop()) {
                // 进行Java nio原生数据封装操作
                register0(promise);
            } else {
                try {
                    // Executor框架进行Java nio原生数据封装操作
                    eventLoop.execute(new Runnable() {
                        @Override
                        public void run() {
                            register0(promise);
                        }
                    });
                } catch (Throwable t) {
                    logger.warn(
                            "Force-closing a channel whose registration task was not accepted by an event loop: {}",
                            AbstractChannel.this, t);
                    closeForcibly();
                    closeFuture.setClosed();
                    safeSetFailure(promise, t);
                }
            }
        }

       在上面的register()注册代码中,先是判断是否已经初始化过eventloop对象,在检查当前的channel是否已经注册过,防止重复注册,最后判断eventLoop的启动线程是否是当前线程,是的话就直接进行Java nio的元素数据封装操作,否则通过Executor线程框架进行Java nio的原生数据封装操作,当代码启动时通过代码跟踪它是直接执行线程的方式启动的,我们先从这个主线走下去,稍后回来看register0()方法。通过代码跟踪我们在线程框架的情况下直接进入了SingleThreadEventExecutor.execute(Runnable task)方法

public void execute(Runnable task) {
        if (task == null) {
            throw new NullPointerException("task");
        }

        // 判断当前线程是否是已经初始化执行的线程
        boolean inEventLoop = inEventLoop();
        // 将当前任务添加到taskQueue中,taskQueue的初始化是在定义EventLoopGroup的时候定义的
        addTask(task);
        if (!inEventLoop) {
            // 开始执行线程
            startThread();
            // 如果已经执行关闭操作
            if (isShutdown()) {
                boolean reject = false;
                try {
                     // 从taskQueue中移除task
                    if (removeTask(task)) {
                        reject = true;
                    }
                } catch (UnsupportedOperationException e) {
                    // The task queue does not support removal so the best thing we can do is to just move on and
                    // hope we will be able to pick-up the task before its completely terminated.
                    // In worst case we will log on termination.
                }
                if (reject) {
                    reject();
                }
            }
        }

        // 往taskQueue队列中添加一个空的线程,默认情况下addTaskWarksUp是false,也是在定义 
        // EventLoopGroup的时候初始化的
        if (!addTaskWakesUp && wakesUpForTask(task)) {
            wakeup(inEventLoop);
        }
    }
private void startThread() {
        // 判断当前线程是否已经启动过
        if (state == ST_NOT_STARTED) {
            // 通过CAS的方式再次确认
            if (STATE_UPDATER.compareAndSet(this, ST_NOT_STARTED, ST_STARTED)) {
                try {
                    // 执行线程
                    doStartThread();
                } catch (Throwable cause) {
                    STATE_UPDATER.set(this, ST_NOT_STARTED);
                    PlatformDependent.throwException(cause);
                }
            }
        }
    }
private void doStartThread() {
        assert thread == null;
        executor.execute(new Runnable() {
            @Override
            public void run() {
                thread = Thread.currentThread();
                if (interrupted) {
                    thread.interrupt();
                }

                boolean success = false;
                // 更新执行时间
                updateLastExecutionTime();
                try {
                    // 执行添加的线程
                    SingleThreadEventExecutor.this.run();
                    success = true;
                } catch (Throwable t) {
                    logger.warn("Unexpected exception from an event executor: ", t);
                } finally {
                    for (;;) {
                        int oldState = state;
                        // 如果关闭了则跳出循环
                        if (oldState >= ST_SHUTTING_DOWN || STATE_UPDATER.compareAndSet(
                                SingleThreadEventExecutor.this, oldState, ST_SHUTTING_DOWN)) {
                            break;
                        }
                    }

                    // Check if confirmShutdown() was called at the end of the loop.
                    if (success && gracefulShutdownStartTime == 0) {
                        if (logger.isErrorEnabled()) {
                            logger.error("Buggy " + EventExecutor.class.getSimpleName() + " implementation; " +
                                    SingleThreadEventExecutor.class.getSimpleName() + ".confirmShutdown() must " +
                                    "be called before run() implementation terminates.");
                        }
                    }

                    try {
                        // Run all remaining tasks and shutdown hooks.
                        for (;;) {
                            // 确认关闭则跳出循环
                            if (confirmShutdown()) {
                                break;
                            }
                        }
                    } finally {
                        try {
                            // 关闭通道选择器或者一些清理操作
                            cleanup();
                        } finally {
                            // Lets remove all FastThreadLocals for the Thread as we are about to terminate and notify
                            // the future. The user may block on the future and once it unblocks the JVM may terminate
                            // and start unloading classes.
                            // See https://github.com/netty/netty/issues/6596.
                            FastThreadLocal.removeAll();

                            STATE_UPDATER.set(SingleThreadEventExecutor.this, ST_TERMINATED);
                            // 锁释放
                            threadLock.release();
                            if (!taskQueue.isEmpty()) {
                                if (logger.isWarnEnabled()) {
                                    logger.warn("An event executor terminated with " +
                                            "non-empty task queue (" + taskQueue.size() + ')');
                                }
                            }
                            terminationFuture.setSuccess(null);
                        }
                    }
                }
            }
        });
    }

通过SingleThreadEventExecutor的三段代码就基本上完成了任务的启动操作的判断,如果要启动doStartThread()方法还得经ThreadPerTaskExecutor类实现的executor()方法,启动了doStartThread()的线程后,通过SingleThreadEventExecutor.this.run()方法的具体实现类NioEventLoop的run()方法

protected void run() {
        for (;;) {
            try {
                try {
                    // 对当前的taskQueue和tailTasks中执行的线程状态进行判断
                    switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
                    case SelectStrategy.CONTINUE:
                        continue;

                    case SelectStrategy.BUSY_WAIT:
                        // fall-through to SELECT since the busy-wait is not supported with NIO

                    case SelectStrategy.SELECT:
                        // 进行Java nio 原生的select开启
                        select(wakenUp.getAndSet(false));

                        // 'wakenUp.compareAndSet(false, true)' is always evaluated
                        // before calling 'selector.wakeup()' to reduce the wake-up
                        // overhead. (Selector.wakeup() is an expensive operation.)
                        //
                        // However, there is a race condition in this approach.
                        // The race condition is triggered when 'wakenUp' is set to
                        // true too early.
                        //
                        // 'wakenUp' is set to true too early if:
                        // 1) Selector is waken up between 'wakenUp.set(false)' and
                        //    'selector.select(...)'. (BAD)
                        // 2) Selector is waken up between 'selector.select(...)' and
                        //    'if (wakenUp.get()) { ... }'. (OK)
                        //
                        // In the first case, 'wakenUp' is set to true and the
                        // following 'selector.select(...)' will wake up immediately.
                        // Until 'wakenUp' is set to false again in the next round,
                        // 'wakenUp.compareAndSet(false, true)' will fail, and therefore
                        // any attempt to wake up the Selector will fail, too, causing
                        // the following 'selector.select(...)' call to block
                        // unnecessarily.
                        //
                        // To fix this problem, we wake up the selector again if wakenUp
                        // is true immediately after selector.select(...).
                        // It is inefficient in that it wakes up the selector for both
                        // the first case (BAD - wake-up required) and the second case
                        // (OK - no wake-up required).

                        if (wakenUp.get()) {
                            selector.wakeup();
                        }
                        // fall through
                    default:
                    }
                } catch (IOException e) {
                    // If we receive an IOException here its because the Selector is messed up. Let's rebuild
                    // the selector and retry. https://github.com/netty/netty/issues/8566
                    // 产生异常时重新尝试selector的开启
                    rebuildSelector0();
                    handleLoopException(e);
                    continue;
                }

                cancelledKeys = 0;
                needsToSelectAgain = false;
                final int ioRatio = this.ioRatio;
                // io执行比率为100
                if (ioRatio == 100) {
                    try {
                        // 进行selectKey事件的对应处理
                        processSelectedKeys();
                    } finally {
                        // Ensure we always run tasks.
                        // 运行taskQueue和tailTasks中所有的线程
                        runAllTasks();
                    }
                } else {
                    final long ioStartTime = System.nanoTime();
                    try {
                        processSelectedKeys();
                    } finally {
                        // Ensure we always run tasks.
                        final long ioTime = System.nanoTime() - ioStartTime;
                        // 有过期时间限制的执行taskQueue和tailTasks中所有的线程
                        runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
                    }
                }
            } catch (Throwable t) {
                handleLoopException(t);
            }
            // Always handle shutdown even if the loop processing threw an exception.
            try {
                if (isShuttingDown()) {
                    closeAll();
                    if (confirmShutdown()) {
                        return;
                    }
                }
            } catch (Throwable t) {
                handleLoopException(t);
            }
        }
    }

       在run()方法中它是一直进行for无限循环的,这样它就可以不断去获取taskQueue或者tailQueue队列中的线程及线程执行情况,在for循环中首先对当前的taskQueue和tailTask中执行的线程状态进行判断,如果是处于select状态的话,通过方法select(wakenUp.getAndSet(false))进行Java nio 原生的select进行初始化并开启,线程状态判断完成后,判断ioRatio的io比率是否为100,如果是,则通过processSelectedKeys()方法直接进行selectKey事件的处理,并且在finally中经过runAllTasks()方法运行taskQueue和tailTasks中的所有线程,如果不满足比率为100时则同样根据processSelectedKeys()方法获取调用selectKey事件处理的时间进行有时间限制的runAllTasks()方法运行taskQueue和tailTasks中的所有线程,我们先看下processSelectedKeys()方法的实现

private void processSelectedKeys() {
        // 判断selectedKeys set集合是否为空,该值可以在NioEventLoop构造函数调用openSelector()方 
        // 法或run()方法异常时通过rebuildSelector0()中调用openSelector()进行赋值
        if (selectedKeys != null) {
            // 直接进行Java nio原生操作,如果没有正确打开selector,则重新打开获取selectKey
            processSelectedKeysOptimized();
        } else {
            // selector的值也是在NioEventLoop构造函数中通过openSelector()时进行定义的
            // 在直接进行Java nio原生操作上,在selectKey有多个值且如果没有正确打开selector,则重 
            // 新打开获取selectKey
            processSelectedKeysPlain(selector.selectedKeys());
        }
    }
private void processSelectedKeysOptimized() {
        for (int i = 0; i < selectedKeys.size; ++i) {
            final SelectionKey k = selectedKeys.keys[i];
            // null out entry in the array to allow to have it GC'ed once the Channel close
            // See https://github.com/netty/netty/issues/2363
            selectedKeys.keys[i] = null;

            final Object a = k.attachment();

            if (a instanceof AbstractNioChannel) {
                // 进行具体的Java nio原生SelectionKey判断操作
                processSelectedKey(k, (AbstractNioChannel) a);
            } else {
                @SuppressWarnings("unchecked")
                NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;

                // 进行具体的Java nio原生的NioTask及SelectionKey判断操作
                processSelectedKey(k, task);
            }

            // 是否需要重新打开selector
            if (needsToSelectAgain) {
                // null out entries in the array to allow to have it GC'ed once the Channel close
                // See https://github.com/netty/netty/issues/2363
                selectedKeys.reset(i + 1);

                selectAgain();
                i = -1;
            }
        }
    }
private void processSelectedKeysPlain(Set<SelectionKey> selectedKeys) {
        // check if the set is empty and if so just return to not create garbage by
        // creating a new Iterator every time even if there is nothing to process.
        // See https://github.com/netty/netty/issues/597
        if (selectedKeys.isEmpty()) {
            return;
        }

        Iterator<SelectionKey> i = selectedKeys.iterator();
        for (;;) {
            final SelectionKey k = i.next();
            final Object a = k.attachment();
            // 消除当前的selectionKey
            i.remove();

            if (a instanceof AbstractNioChannel) {
                processSelectedKey(k, (AbstractNioChannel) a);
            } else {
                @SuppressWarnings("unchecked")
                NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;
                processSelectedKey(k, task);
            }

            // 是否还有值
            if (!i.hasNext()) {
                break;
            }

            if (needsToSelectAgain) {
                selectAgain();
                selectedKeys = selector.selectedKeys();

                // Create the iterator again to avoid ConcurrentModificationException
                if (selectedKeys.isEmpty()) {
                    break;
                } else {
                    i = selectedKeys.iterator();
                }
            }
        }
    }

       在processSelectedKeys()方法及相关方法中进行了SelectionKey的事件处理操作,操作底层都是使用Java nio的原生类进行操作的,这里就不在贴出来了,我们接着看下runAllTasks(ioTime * (100 - ioRatio) / ioRatio)方法

protected boolean runAllTasks(long timeoutNanos) {
        // 获取定时任务执行的线程到taskQueue中
        fetchFromScheduledTaskQueue();
        // 获取taskQueue中线程 
        Runnable task = pollTask();
        if (task == null) {
            // 执行所有在tailTasks中的线程
            afterRunningAllTasks();
            return false;
        }

        final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;
        long runTasks = 0;
        long lastExecutionTime;
        for (;;) {
            // 启动线程
            safeExecute(task);

            runTasks ++;

            // Check timeout every 64 tasks because nanoTime() is relatively expensive.
            // XXX: Hard-coded value - will make it configurable if it is really a problem.
            if ((runTasks & 0x3F) == 0) {
                lastExecutionTime = ScheduledFutureTask.nanoTime();
                // 判断是否已经超过指定的定长时间
                if (lastExecutionTime >= deadline) {
                    break;
                }
            }

            // 从taskQueue中移除线程
            task = pollTask();
            if (task == null) {
                lastExecutionTime = ScheduledFutureTask.nanoTime();
                break;
            }
        }

         // 执行所有在tailTasks中的线程
        afterRunningAllTasks();
        this.lastExecutionTime = lastExecutionTime;
        return true;
    }

        通过上面的线程执行就完成了整个流程的服务端的启动,我们这里还要回到AbstractChannel.AbstractUnsafe.register0()方法中看看是如何注册的

private void register0(ChannelPromise promise) {
            try {
                // check if the channel is still open as it could be closed in the mean time when the register
                // call was outside of the eventLoop
                if (!promise.setUncancellable() || !ensureOpen(promise)) {
                    return;
                }
                boolean firstRegistration = neverRegistered;
                // 调用Java nio原生register进行注册操作
                doRegister();
                neverRegistered = false;
                registered = true;

                // Ensure we call handlerAdded(...) before we actually notify the promise. This is needed as the
                // user may already fire events through the pipeline in the ChannelFutureListener.
                pipeline.invokeHandlerAddedIfNeeded();

                safeSetSuccess(promise);
                // 对ChannelInboundHandler.channelRegistered()的封装操作
                pipeline.fireChannelRegistered();
                // Only fire a channelActive if the channel has never been registered. This prevents firing
                // multiple channel actives if the channel is deregistered and re-registered.
                if (isActive()) {
                    // 判断是否已经注册过
                    if (firstRegistration) {
                        // 对ChannelInboundHandler.channelRegistered()的封装操作
                        pipeline.fireChannelActive();
                    } else if (config().isAutoRead()) {// 是否启用了自动读取操作
                        // This channel was registered before and autoRead() is set. This means we need to begin read
                        // again so that we process inbound data.
                        //
                        // See https://github.com/netty/netty/issues/4805
                        // 如果在没有已经绑定的情况下,也就是初始化的时候,那么selectionKey初 
                        // 始化为SelectionKey.OP_READ
                        beginRead();
                    }
                }
            } catch (Throwable t) {
                // Close the channel directly to avoid FD leak.
                closeForcibly();
                closeFuture.setClosed();
                safeSetFailure(promise, t);
            }
        }

        在register0()方法中我们完成了Java nio原生方法的注册调用以及ChannelInboundHandler的注册工作,同时对注册和没有注册情况时的不同操作判断,以上就是所有的启动过程。需要注意的是如果跟踪代码的时候断点太多可能不会进入到register0()方法,可以少用点断点直接进入到register0()方法观察。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值