【十一】dubbo源码分析之服务提供方接收请求+处理

一、简介

1.服务提供方接收请求的大致流程

默认情况下 Dubbo 使用 Netty 作为底层的通信框架。

1.1.Netty 检测到有数据入站后,通过解码器对数据进行解码。

解码器将数据包解析成 Request 对象。

入站开始的调用链Netty相关部分

Netty相关部分:

 DefaultThreadFactory$DefaultRunnableDecorator.run -------->SingleThreadEventExecutor.run-------->NioEventLoop.run-------->

NioEventLoop.processSelectedKeys-------->NioEventLoop.processSelectedKeysOptimized-------->NioEventLoop.processSelectedKey-------->

AbstractNioByteChannel$NioByteUnsafe.read-------->DefaultChannelPipeline.fireChannelRead-------->AbstractChannelHandlerContext.invokeChannelRead-------->

DefaultChannelPipeline$HeadContext.channelRead-------->AbstractChannelHandlerContext.fireChannelRead-------->

AbstractChannelHandlerContext.invokeChannelRead-------->ByteToMessageDecoder.channelRead-------->ByteToMessageDecoder.fireChannelRead-------->

AbstractChannelHandlerContext.fireChannelRead-------->AbstractChannelHandlerContext.invokeChannelRead-------->NettyServerHandler.channelRead

1.2.将解码后的数据传递给下一个入站处理器的指定方法。

后面的调用链:

NettyServerHandler.channelRead-------->AbstractPeer.received-------->MultiMessageHandler.received-------->HeartbeatHandler.received-------->AllChannelHandler.received-------->ExecutorService.execute

 AllChannelHandler 将该对象封装到 Runnable 实现类对象中,并将 Runnable 放入线程池中执行后续的调用逻辑。

二、服务提供方接收请求源码分析 

2. NettyServerHandler.channelRead

 public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {

        // 获取 NettyChannel
        NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler);
        try {

            // 继续向下调用
            handler.received(channel, msg);
        } finally {
            NettyChannel.removeChannelIfDisconnected(ctx.channel());
        }
    }

做了一件事

根据一些信息获取 NettyChannel 实例,然后将 NettyChannel 实例以及 Request 对象向下传递

 3.AllChannelHandler 及线程派发模型

Dispatcher 是dubbo的线程派发器,它的职责创建具有线程派发能力的 ChannelHandler,比如 AllChannelHandler、MessageOnlyChannelHandler 和 ExecutionChannelHandler 等,其本身并不具备线程派发能力。

Dubbo 支持 5 种不同的线程派发策略:

策略用途
all所有消息都派发到线程池,包括请求,响应,连接事件,断开事件等。(默认
direct所有消息都不派发到线程池,全部在 IO 线程上直接执行
message只有请求响应消息派发到线程池,其它消息均在 IO 线程上执行
execution只有请求消息派发到线程池,不含响应。其它消息均在 IO 线程上执行
connection在 IO 线程上,将连接断开事件放入队列,有序逐个执行,其它消息派发到线程池

  AllChannelHandler源码

public class AllChannelHandler extends WrappedChannelHandler {

    public AllChannelHandler(ChannelHandler handler, URL url) {
        super(handler, url);
    }

    /** 处理连接事件 */
    @Override
    public void connected(Channel channel) throws RemotingException {

         // 获取线程池
        ExecutorService cexecutor = getExecutorService();
        try {

            // 将连接事件派发到线程池中处理
            cexecutor.execute(new ChannelEventRunnable(channel, handler, ChannelState.CONNECTED));
        } catch (Throwable t) {
            throw new ExecutionException("connect event", channel, getClass() + " error when process connected event .", t);
        }
    }


     /** 处理断开事件 */
    @Override
    public void disconnected(Channel channel) throws RemotingException {
        ExecutorService cexecutor = getExecutorService();
        try {
            cexecutor.execute(new ChannelEventRunnable(channel, handler, ChannelState.DISCONNECTED));
        } catch (Throwable t) {
            throw new ExecutionException("disconnect event", channel, getClass() + " error when process disconnected event .", t);
        }
    }

    /** 处理请求和响应消息,这里的 message 变量类型可能是 Request,也可能是 Response */
    @Override
    public void received(Channel channel, Object message) throws RemotingException {
        ExecutorService cexecutor = getExecutorService();
        try {

            // 将请求和响应消息派发到线程池中处理
            cexecutor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message));
        } catch (Throwable t) {
            //TODO A temporary solution to the problem that the exception information can not be sent to the opposite end after the thread pool is full. Need a refactoring
            //fix The thread pool is full, refuses to call, does not return, and causes the consumer to wait for time out
        	if(message instanceof Request && t instanceof RejectedExecutionException){
        		Request request = (Request)message;

                // 如果通信方式为双向通信,此时将 Server side ... threadpool is exhausted 
                // 错误信息封装到 Response 中,并返回给服务消费方。
        		if(request.isTwoWay()){
        			String msg = "Server side(" + url.getIp() + "," + url.getPort() + ") threadpool is exhausted ,detail msg:" + t.getMessage();
        			Response response = new Response(request.getId(), request.getVersion());
        			response.setStatus(Response.SERVER_THREADPOOL_EXHAUSTED_ERROR);
        			response.setErrorMessage(msg);

                     // 返回包含错误信息的 Response 对象
        			channel.send(response);
        			return;
        		}
        	}
            throw new ExecutionException(message, channel, getClass() + " error when process received event .", t);
        }
    }

    /** 处理异常信息 */
    @Override
    public void caught(Channel channel, Throwable exception) throws RemotingException {
        ExecutorService cexecutor = getExecutorService();
        try {
            cexecutor.execute(new ChannelEventRunnable(channel, handler, ChannelState.CAUGHT, exception));
        } catch (Throwable t) {
            throw new ExecutionException("caught event", channel, getClass() + " error when process caught event .", t);
        }
    }
}

请求对象会被封装 ChannelEventRunnable 中,ChannelEventRunnable 将会是服务调用过程的新起点。

三、服务提供方调用自己内部逻辑

ChannelEventRunnable 

    @Override
    public void run() {

         // 检测通道状态,对于请求或响应消息,此时 state = RECEIVED
        if (state == ChannelState.RECEIVED) {
            try {

                // 将 channel 和 message 传给 ChannelHandler 对象,进行后续的调用
                handler.received(channel, message);
            } catch (Exception e) {
                logger.warn("ChannelEventRunnable handle " + state + " operation error, channel is " + channel
                        + ", message is " + message, e);
            }

        // 其他消息类型通过 switch 进行处理
        } else {
            switch (state) {
            case CONNECTED:
                try {
                    handler.connected(channel);
                } catch (Exception e) {
                    logger.warn("ChannelEventRunnable handle " + state + " operation error, channel is " + channel, e);
                }
                break;
            case DISCONNECTED:
                try {
                    handler.disconnected(channel);
                } catch (Exception e) {
                    logger.warn("ChannelEventRunnable handle " + state + " operation error, channel is " + channel, e);
                }
                break;
            case SENT:
                try {
                    handler.sent(channel, message);
                } catch (Exception e) {
                    logger.warn("ChannelEventRunnable handle " + state + " operation error, channel is " + channel
                            + ", message is " + message, e);
                }
            case CAUGHT:
                try {
                    handler.caught(channel, exception);
                } catch (Exception e) {
                    logger.warn("ChannelEventRunnable handle " + state + " operation error, channel is " + channel
                            + ", message is: " + message + ", exception is " + exception, e);
                }
                break;
            default:
                logger.warn("unknown state: " + state + ", message is " + message);
            }
        }

    }

ChannelEventRunnable 是一个中转站,它的 run 方法中并不包含具体的调用逻辑,仅用于将参数传给其他 ChannelHandler 对象进行处理,该对象类型为 DecodeHandler。

对请求响应、异常、建立连接、释放连接、sent事件有不同的处理逻辑

DecodeHandler

public class DecodeHandler extends AbstractChannelHandlerDelegate {

    private static final Logger log = LoggerFactory.getLogger(DecodeHandler.class);

    public DecodeHandler(ChannelHandler handler) {
        super(handler);
    }

    @Override
    public void received(Channel channel, Object message) throws RemotingException {
        if (message instanceof Decodeable) {
            decode(message);
        }

        if (message instanceof Request) {

           //debug的时候走了这里
            decode(((Request) message).getData());
        }

        if (message instanceof Response) {
            decode(((Response) message).getResult());
        }

        handler.received(channel, message);
    }

    private void decode(Object message) {
        if (message != null && message instanceof Decodeable) {
            try {
                ((Decodeable) message).decode();
                if (log.isDebugEnabled()) {
                    log.debug("Decode decodeable message " + message.getClass().getName());
                }
            } catch (Throwable e) {
                if (log.isWarnEnabled()) {
                    log.warn("Call Decodeable.decode failed: " + e.getMessage(), e);
                }
            } // ~ end of catch
        } // ~ end of if
    } // ~ end of method decode

}

DecodeHandler 主要是包含了一些解码逻辑。请求解码可在 IO 线程上执行,也可在线程池中执行,这个取决于运行时配置。

DecodeHandler 存在的意义就是保证请求或响应对象可在线程池中被解码。

解码完毕后,完全解码后的 Request 对象会继续向后传递。

 HeaderExchangeHandler

public class HeaderExchangeHandler implements ChannelHandlerDelegate {

    protected static final Logger logger = LoggerFactory.getLogger(HeaderExchangeHandler.class);

    public static String KEY_READ_TIMESTAMP = HeartbeatHandler.KEY_READ_TIMESTAMP;

    public static String KEY_WRITE_TIMESTAMP = HeartbeatHandler.KEY_WRITE_TIMESTAMP;

    private final ExchangeHandler handler;

    public HeaderExchangeHandler(ExchangeHandler handler) {
        if (handler == null) {
            throw new IllegalArgumentException("handler == null");
        }
        this.handler = handler;
    }

 
    Response handleRequest(ExchangeChannel channel, Request req) throws RemotingException {
        Response res = new Response(req.getId(), req.getVersion());

        // 检测请求是否合法,不合法则返回状态码为 BAD_REQUEST 的响应
        if (req.isBroken()) {
            Object data = req.getData();

            String msg;
            if (data == null) msg = null;
            else if (data instanceof Throwable) msg = StringUtils.toString((Throwable) data);
            else msg = data.toString();
            res.setErrorMessage("Fail to decode request due to: " + msg);

             // 设置 BAD_REQUEST 状态
            res.setStatus(Response.BAD_REQUEST);

            return res;
        }

         // 获取 data 字段值,也就是 RpcInvocation 对象
        // find handler by message class.
        Object msg = req.getData();
        try {

            // 继续向下调用
            // handle data.
            Object result = handler.reply(channel, msg);

           // 设置 OK 状态码
            res.setStatus(Response.OK);

             // 设置调用结果
            res.setResult(result);
        } catch (Throwable e) {

            // 若调用过程出现异常,则设置 SERVICE_ERROR,表示服务端异常
            res.setStatus(Response.SERVICE_ERROR);
            res.setErrorMessage(StringUtils.toString(e));
        }
        return res;
    }

    @Override
    public void received(Channel channel, Object message) throws RemotingException {
        channel.setAttribute(KEY_READ_TIMESTAMP, System.currentTimeMillis());
        ExchangeChannel exchangeChannel = HeaderExchangeChannel.getOrAddChannel(channel);
        try {
 
            // 处理请求对象
            if (message instanceof Request) {
                // handle request.
                Request request = (Request) message;
                if (request.isEvent()) {

                    // 处理事件
                    handlerEvent(channel, request);

                 // 处理普通的请求
                } else {

                    // 双向通信
                    if (request.isTwoWay()) {

                        // 向后调用服务,并得到调用结果
                        Response response = handleRequest(exchangeChannel, request);

                        // 将调用结果返回给服务消费端
                        channel.send(response);

                    // 如果是单向通信,仅向后调用指定服务即可,无需返回调用结果
                    } else {
                        handler.received(exchangeChannel, request.getData());
                    }
                }

             // 处理响应对象,服务消费方会执行此处逻辑,后面分析
            } else if (message instanceof Response) {
                handleResponse(channel, (Response) message);
            } else if (message instanceof String) {
                if (isClientSide(channel)) {
                    Exception e = new Exception("Dubbo client can not supported string message: " + message + " in channel: " + channel + ", url: " + channel.getUrl());
                    logger.error(e.getMessage(), e);
                } else {
                    String echo = handler.telnet(channel, (String) message);
                    if (echo != null && echo.length() > 0) {
                        channel.send(echo);
                    }
                }
            } else {
                handler.received(exchangeChannel, message);
            }
        } finally {
            HeaderExchangeChannel.removeChannelIfDisconnected(channel);
        }
    }
}

这个类中有比较清晰的请求和响应逻辑。

对于双向通信,HeaderExchangeHandler 首先向后进行调用,得到调用结果。然后将调用结果封装到 Response 对象中,最后再将该对象返回给服务消费方。

如果请求不合法,或者调用失败,则将错误信息封装到 Response 对象中,并返回给服务消费方。

DubboProtocol

public class DubboProtocol extends AbstractProtocol {

    public static final String NAME = "dubbo";

    public static final int DEFAULT_PORT = 20880;
    private static final String IS_CALLBACK_SERVICE_INVOKE = "_isCallBackServiceInvoke";
    private static DubboProtocol INSTANCE;
    private final Map<String, ExchangeServer> serverMap = new ConcurrentHashMap<String, ExchangeServer>(); // <host:port,Exchanger>
    private final Map<String, ReferenceCountExchangeClient> referenceClientMap = new ConcurrentHashMap<String, ReferenceCountExchangeClient>(); // <host:port,Exchanger>
    private final ConcurrentMap<String, LazyConnectExchangeClient> ghostClientMap = new ConcurrentHashMap<String, LazyConnectExchangeClient>();
    private final ConcurrentMap<String, Object> locks = new ConcurrentHashMap<String, Object>();
    private final Set<String> optimizers = new ConcurrentHashSet<String>();
    //consumer side export a stub service for dispatching event
    //servicekey-stubmethods
    private final ConcurrentMap<String, String> stubServiceMethodsMap = new ConcurrentHashMap<String, String>();
    private ExchangeHandler requestHandler = new ExchangeHandlerAdapter() {

        @Override
        public Object reply(ExchangeChannel channel, Object message) throws RemotingException {
            if (message instanceof Invocation) {
                Invocation inv = (Invocation) message;

                // 获取 Invoker 实例
                Invoker<?> invoker = getInvoker(channel, inv);
                // need to consider backward-compatibility if it's a callback
                if (Boolean.TRUE.toString().equals(inv.getAttachments().get(IS_CALLBACK_SERVICE_INVOKE))) {

                    // 回调相关,忽略
                    String methodsStr = invoker.getUrl().getParameters().get("methods");
                    boolean hasMethod = false;
                    if (methodsStr == null || methodsStr.indexOf(",") == -1) {
                        hasMethod = inv.getMethodName().equals(methodsStr);
                    } else {
                        String[] methods = methodsStr.split(",");
                        for (String method : methods) {
                            if (inv.getMethodName().equals(method)) {
                                hasMethod = true;
                                break;
                            }
                        }
                    }
                    if (!hasMethod) {
                        logger.warn(new IllegalStateException("The methodName " + inv.getMethodName()
                                + " not found in callback service interface ,invoke will be ignored."
                                + " please update the api interface. url is:"
                                + invoker.getUrl()) + " ,invocation is :" + inv);
                        return null;
                    }
                }
                RpcContext.getContext().setRemoteAddress(channel.getRemoteAddress());

                // 通过 Invoker 调用具体的服务
                return invoker.invoke(inv);
            }
            throw new RemotingException(channel, "Unsupported request: "
                    + (message == null ? null : (message.getClass().getName() + ": " + message))
                    + ", channel: consumer: " + channel.getRemoteAddress() + " --> provider: " + channel.getLocalAddress());
        }

        @Override
        public void received(Channel channel, Object message) throws RemotingException {
            if (message instanceof Invocation) {
                reply((ExchangeChannel) channel, message);
            } else {
                super.received(channel, message);
            }
        }

        

    public DubboProtocol() {
        INSTANCE = this;
    }


    Invoker<?> getInvoker(Channel channel, Invocation inv) throws RemotingException {
        boolean isCallBackServiceInvoke = false;
        boolean isStubServiceInvoke = false;
        int port = channel.getLocalAddress().getPort();
        String path = inv.getAttachments().get(Constants.PATH_KEY);
        // if it's callback service on client side
        isStubServiceInvoke = Boolean.TRUE.toString().equals(inv.getAttachments().get(Constants.STUB_EVENT_KEY));
        if (isStubServiceInvoke) {
            port = channel.getRemoteAddress().getPort();
        }
        //callback
        isCallBackServiceInvoke = isClientSide(channel) && !isStubServiceInvoke;
        if (isCallBackServiceInvoke) {
            path = inv.getAttachments().get(Constants.PATH_KEY) + "." + inv.getAttachments().get(Constants.CALLBACK_SERVICE_KEY);
            inv.getAttachments().put(IS_CALLBACK_SERVICE_INVOKE, Boolean.TRUE.toString());
        }

        // 计算 service key,格式为 groupName/serviceName:serviceVersion:port。比如:
        //   dubbo/com.alibaba.dubbo.demo.DemoService:XXX.XXX.XX:20880
        String serviceKey = serviceKey(port, path, inv.getAttachments().get(Constants.VERSION_KEY), inv.getAttachments().get(Constants.GROUP_KEY));


        // 从 exporterMap 查找与 serviceKey 相对应的 DubboExporter 对象,
        // 服务导出过程中会将 <serviceKey, DubboExporter> 映射关系存储到 exporterMap 集合中
        DubboExporter<?> exporter = (DubboExporter<?>) exporterMap.get(serviceKey);

        if (exporter == null)
            throw new RemotingException(channel, "Not found exported service: " + serviceKey + " in " + exporterMap.keySet() + ", may be version or group mismatch " + ", channel: consumer: " + channel.getRemoteAddress() + " --> provider: " + channel.getLocalAddress() + ", message:" + inv);


        // 获取 Invoker 对象,并返回
        return exporter.getInvoker();
    }
}

reply方法主要做了几件事:

1.getInvoker方法获取与指定服务对应的 Invoker 实例

getInvoker方法做了几件事:

(1)计算service key 格式是:  groupName/serviceName:serviceVersion:port

  debug的时候这里是:org.apache.dubbo.samples.api.client.HelloService:20880

  (2) 从exporterMap中查找与serviceKey对应的DubboExporter对象

debug的时候这里是:

(3)exporter.getInvoker,获取Invoker对象并返回

2.通过 Invoker 的 invoke 方法调用服务逻辑

这个后面的调用链是

protocolFilterWrapper.invoke-------->EchoFilter.invoke-------->protocolFilterWrapper.invoke-------->ClassLoaderFilter.invoke-------->

protocolFilterWrapper.invoke-------->GenericFilter.invoke-------->protocolFilterWrapper.invoke-------->ContextFilter.invoke-------->

protocolFilterWrapper.invoke-------->TraceFilter.invoke-------->protocolFilterWrapper.invoke-------->TimeoutFilter.invoke-------->

protocolFilterWrapper.invoke-------->MonitorFilter.invoke-------->protocolFilterWrapper.invoke-------->ExceptionFilter.invoke-------->

InvokerWrapper.invoke------->DelegateProviderMetaDataInvoker.invoke

DelegateProviderMetaDataInvoker.invoke

    @Override
    public Result invoke(Invocation invocation) throws RpcException {
        return invoker.invoke(invocation);
    }

 这个方法里面的invoker是JavassistProxyFactory

AbstractProxyInvoker.invoke

    @Override
    public Result invoke(Invocation invocation) throws RpcException {
        try {

            // 调用 doInvoke 执行后续的调用,并将调用结果封装到 RpcResult 中
            return new RpcResult(doInvoke(proxy, invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments()));
        } catch (InvocationTargetException e) {
            return new RpcResult(e.getTargetException());
        } catch (Throwable e) {
            throw new RpcException("Failed to invoke remote proxy method " + invocation.getMethodName() + " to " + getUrl() + ", cause: " + e.getMessage(), e);
        }
    }

这个方法里面的proxy是AnnotationHelloServiceConsumer了,在DubboProtocol.getInvoker中就决定这里的invoker是什么,invoker里面的proxy是什么

调用 doInvoke 执行后续的调用,并将调用结果封装到 RpcResult 中

doInvoke 是一个抽象方法,这个需要由具体的 Invoker 实例实现。

Invoker 实例是在运行时通过 JavassistProxyFactory 创建的。

JavassistProxyFactory

public class JavassistProxyFactory extends AbstractProxyFactory {

    @Override
    @SuppressWarnings("unchecked")
    public <T> T getProxy(Invoker<T> invoker, Class<?>[] interfaces) {
        return (T) Proxy.getProxy(interfaces).newInstance(new InvokerInvocationHandler(invoker));
    }

    @Override
    public <T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) {
        // TODO Wrapper cannot handle this scenario correctly: the classname contains '$'
        final Wrapper wrapper = Wrapper.getWrapper(proxy.getClass().getName().indexOf('$') < 0 ? proxy.getClass() : type);

        // 创建匿名类对象
        return new AbstractProxyInvoker<T>(proxy, type, url) {
            @Override
            protected Object doInvoke(T proxy, String methodName,
                                      Class<?>[] parameterTypes,
                                      Object[] arguments) throws Throwable {

                // 调用 invokeMethod 方法进行后续的调用
                return wrapper.invokeMethod(proxy, methodName, parameterTypes, arguments);
            }
        };
    }

}

Wrapper 是一个抽象类,其中 invokeMethod 是一个抽象方法。

Dubbo 会在运行时通过 Javassist 框架为 Wrapper 生成实现类,并实现 invokeMethod 方法,该方法最终会根据调用具体的服务。

反编译后可以看到这个wrapper的内容

Wrapper

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.alibaba.dubbo.common.bytecode;

import com.alibaba.dubbo.common.bytecode.ClassGenerator.DC;
import java.lang.reflect.InvocationTargetException;
import java.util.Map;
import org.apache.dubbo.samples.annotation.impl.AnnotationHelloServiceImpl;

public class Wrapper1 extends Wrapper implements DC {
    public static String[] pns;
    public static Map pts;
    public static String[] mns;
    public static String[] dmns;
    public static Class[] mts0;

    public Wrapper1() {
    }

    public Class getPropertyType(String var1) {
        return (Class)pts.get(var1);
    }

    public Object getPropertyValue(Object var1, String var2) {
        try {
            AnnotationHelloServiceImpl var3 = (AnnotationHelloServiceImpl)var1;
        } catch (Throwable var5) {
            throw new IllegalArgumentException(var5);
        }

        throw new NoSuchPropertyException("Not found property \"" + var2 + "\" filed or setter method in class org.apache.dubbo.samples.annotation.impl.AnnotationHelloServiceImpl.");
    }

    public void setPropertyValue(Object var1, String var2, Object var3) {
        try {
            AnnotationHelloServiceImpl var4 = (AnnotationHelloServiceImpl)var1;
        } catch (Throwable var6) {
            throw new IllegalArgumentException(var6);
        }

        throw new NoSuchPropertyException("Not found property \"" + var2 + "\" filed or setter method in class org.apache.dubbo.samples.annotation.impl.AnnotationHelloServiceImpl.");
    }

    public Object invokeMethod(Object var1, String var2, Class[] var3, Object[] var4) throws InvocationTargetException {
        AnnotationHelloServiceImpl var5;
        try {

            // 类型转换
            var5 = (AnnotationHelloServiceImpl)var1;
        } catch (Throwable var8) {
            throw new IllegalArgumentException(var8);
        }

        try {

            // 根据方法名调用指定的方法
            if ("sayHello".equals(var2) && var3.length == 1) {
                return var5.sayHello((String)var4[0]);
            }
        } catch (Throwable var9) {
            throw new InvocationTargetException(var9);
        }

        throw new NoSuchMethodException("Not found method \"" + var2 + "\" in class org.apache.dubbo.samples.annotation.impl.AnnotationHelloServiceImpl.");
    }

    public String[] getPropertyNames() {
        return pns;
    }

    public String[] getMethodNames() {
        return mns;
    }

    public boolean hasProperty(String var1) {
        return pts.containsKey(var1);
    }

    public String[] getDeclaredMethodNames() {
        return dmns;
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值