开发可复用的独立 Netty 服务

一、Netty 简介

Netty 是一个基于 Java NIO 封装的高性能网络编程框架,它简化了网络编程的复杂性,提供了强大的异步事件驱动机制,广泛应用于构建高性能、可扩展的网络服务器和客户端。下面将详细介绍如何开发一个可被其他模块共用的独立 Netty 服务。

二、开发环境准备

在开始之前,你需要确保已经安装了 Java 开发环境(JDK 8 及以上),并且配置好了 Maven 或者 Gradle 项目管理工具。以 Maven 为例,在 pom.xml 中添加 Netty 依赖:

<dependencies>
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.87.Final</version>
    </dependency>
</dependencies>

三、开发独立 Netty 服务

1. 服务启动类

首先创建一个服务启动类,用于初始化 Netty 服务并启动。

package com.xxx.xxx.netty.server;

import com.xxx.xxx.netty.handler.heartbeat.HeartbeatChannelInitializer;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LoggingHandler;
import lombok.extern.slf4j.Slf4j;

/**
 * TCP服务端
 */
@Slf4j
public class NettyTCPServer {
    private static final int INT_ZERO = 0;

    private static final int DEFAULT_BOOS_EVENT_LOOP_THREAD_NUM = 1;

    // 工作线程默认传入0,当传入0时取CPU逻辑核心数*2
    private static final int DEFAULT_WORKER_EVENT_LOOP_THREAD_NUM = INT_ZERO;

    private static final String EMPTY_NAME = "";

    private String name;

    private final int port;

    private int bossEventLoopGroupNum;

    private int workerEventLoopGroupNum;

    private ChannelInitializer<?> channelInitializer;

    public NettyTCPServer(String name,
                          int port,
                          int bossEventLoopGroupNum,
                          int workerEventLoopGroupNum,
                          ChannelInitializer<?> channelInitializer) {
        this.name = name;
        this.port = port;
        this.bossEventLoopGroupNum = bossEventLoopGroupNum;
        this.workerEventLoopGroupNum = workerEventLoopGroupNum;
        this.channelInitializer = channelInitializer;
    }

    public NettyTCPServer(int port) {
        this(EMPTY_NAME, port);
    }

    public NettyTCPServer(String name, int port) {
        this(name,
                port,
                DEFAULT_BOOS_EVENT_LOOP_THREAD_NUM,
                DEFAULT_WORKER_EVENT_LOOP_THREAD_NUM,
                new HeartbeatChannelInitializer(name));
    }


    public NettyTCPServer setName(String name) {
        this.name = name;
        return this;
    }

    public NettyTCPServer setBossEventLoopGroupNum(int bossEventLoopGroupNum) {
        this.bossEventLoopGroupNum = bossEventLoopGroupNum;
        return this;
    }

    public NettyTCPServer setWorkerEventLoopGroupNum(int workerEventLoopGroupNum) {
        this.workerEventLoopGroupNum = workerEventLoopGroupNum;
        return this;
    }

    public NettyTCPServer setChannelInitializer(ChannelInitializer<?> channelInitializer) {
        this.channelInitializer = channelInitializer;
        return this;
    }

    /**
     * 启动服务
     */
    public void run() {
        this.validated();

        EventLoopGroup bossEventLoopGroup = new NioEventLoopGroup(bossEventLoopGroupNum);
        EventLoopGroup workerEventLoopGroup = new NioEventLoopGroup(workerEventLoopGroupNum);

        try {
            ChannelFuture bindFuture = new ServerBootstrap().group(bossEventLoopGroup, workerEventLoopGroup)
                    .channel(NioServerSocketChannel.class)
                    .handler(new LoggingHandler())
                    .childHandler(channelInitializer)
                    // 关闭Nagle算法
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    // 终端TCP心跳
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .bind(port)
                    .sync();
            log.info("{}服务端启动成功:{}", name, bindFuture.channel());

            ChannelFuture closeFuture = bindFuture.channel().closeFuture();
            closeFuture.addListener(listener -> {
                if (listener.isSuccess()) {
                    log.info("{}服务端关闭", name);
                }
            });
            closeFuture.sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            workerEventLoopGroup.shutdownGracefully();
            bossEventLoopGroup.shutdownGracefully();
        }
    }

    /**
     * 参数校验
     */
    private void validated() {
        if (1024 > this.port || 49151 < this.port) {
            throw new RuntimeException("非法端口[" + this.port + "]");
        }

        if (this.channelInitializer == null) {
            throw new RuntimeException("通道构造器不允许为空");
        }

        if (this.bossEventLoopGroupNum <= INT_ZERO) {
            throw new RuntimeException("连接反应器线程数非法");
        }

        if (this.workerEventLoopGroupNum < INT_ZERO) {
            throw new RuntimeException("工作反应器线程数非法");
        }
    }
}

在上述代码中,NettyTCPServer 类负责启动 Netty 服务。EventLoopGroup 是 Netty 中的线程池,bossGroup 用于处理客户端连接请求,workerGroup 用于处理网络读写操作。ServerBootstrap 是服务端启动辅助类,通过它可以配置服务端的各种参数。

2. 客户端启动类

该客户端实现了 Netty TCP 连接的基本功能,具备可配置的通道处理器、连接状态监听和心跳机制。适用于需要长连接通信的场景(如即时通讯、物联网设备通信)。实际使用中需根据业务需求补充重连机制、异常处理和监控指标等功能,以提升系统的健壮性和可观测性。

package com.xxx.xxx.netty.client;

import com.xxx.xxx.netty.handler.heartbeat.HeartbeatChannelInitializer;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

/**
 * NettyTCP客户端
 */
@Slf4j
public class NettyTCPClient {
    private final String host;

    private final int port;

    @Setter
    @Accessors(chain = true)
    private ChannelInitializer<?> channelInitializer;

    @Setter
    @Accessors(chain = true)
    private GenericFutureListener<? extends Future<? super Void>> closeListener;

    @Setter
    @Getter
    private EventLoopGroup eventLoopGroup;

    public NettyTCPClient(String host, int port, ChannelInitializer<?> channelInitializer, GenericFutureListener<? extends Future<? super Void>> closeListener, EventLoopGroup eventLoopGroup) {
        this.host = host;
        this.port = port;
        this.channelInitializer = channelInitializer;
        this.closeListener = closeListener;
        this.eventLoopGroup = eventLoopGroup;
    }

    public NettyTCPClient(String host, int port, ChannelInitializer<?> channelInitializer, GenericFutureListener<? extends Future<? super Void>> closeListener) {
        this.host = host;
        this.port = port;
        this.channelInitializer = channelInitializer;
        this.closeListener = closeListener;
    }

    public NettyTCPClient(String host, int port, ChannelInitializer<?> channelInitializer) {
        this(host, port, channelInitializer, null);
    }

    public NettyTCPClient(String host, int port) {
        this(host, port, new HeartbeatChannelInitializer(), null);
    }

    /**
     * 客户端启动
     *
     * @param blockCurrentThread 开启客户端连接后当前调用线程是否保持阻塞
     * @return 客户端channel
     */
    public Channel run(boolean blockCurrentThread) {
        this.validated();
        if (eventLoopGroup == null) {
            eventLoopGroup = new NioEventLoopGroup();
        }

        try {
            ChannelFuture connectFuture = new Bootstrap().group(eventLoopGroup)
                    .channel(NioSocketChannel.class)
                    .remoteAddress(host, port)
                    .handler(channelInitializer)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .option(ChannelOption.SO_KEEPALIVE, true)
                    .connect()
                    .addListener((listener) -> {
                        if (listener.isSuccess()) {
                            log.info("连接[/{}:{}]成功", host, port);
                        } else {
                            log.info("连接[/{}:{}]失败", host, port);
                        }
                    })
                    .sync();

            if (!connectFuture.isSuccess()) {
                return null;
            }
            Channel channel = connectFuture.channel();

            // 添加关闭事件
            ChannelFuture closeFuture = channel.closeFuture();
            if (closeListener != null) {
                closeFuture.addListener(closeListener);
            }
            if (blockCurrentThread) {
                closeFuture.sync();
            }
            return channel;
        } catch (InterruptedException e) {
            log.error("启动Netty客户端失败", e);
            return null;
        }
    }

    private void validated() {
        if (StringUtils.isBlank(this.host)) {
            throw new RuntimeException("远端主机地址不允许为空");
        }

        if (1024 > this.port || 49151 < this.port) {
            throw new RuntimeException("非法远端主机端口[" + this.port + "]");
        }

        if (this.channelInitializer == null) {
            throw new RuntimeException("通道构造器不允许为空");
        }
    }
}

3. 心跳处理器

package com.xxx.xxx.netty.handler.heartbeat;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.timeout.IdleStateHandler;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.apache.commons.lang3.StringUtils;

import java.util.concurrent.TimeUnit;

/**
 * 心跳处理器初始化器
 */
public class HeartbeatChannelInitializer extends ChannelInitializer<SocketChannel> {
    private static final int INT_ZERO = 0;

    private static final int DEFAULT_READ_TIMEOUT = 30;

    private static final int DEFAULT_WRITE_TIMEOUT = 30;

    private static final int DEFAULT_READ_WRITE_TIMEOUT = -1;

    private int readTimeout;

    private int writeTimeout;

    private int readOrWriteTimeout;

    private String handlerPrefix;

    public HeartbeatChannelInitializer(int readTimeout,
                                       int writeTimeout,
                                       int readOrWriteTimeout,
                                       String handlerPrefix) {
        this.readTimeout = readTimeout;
        this.writeTimeout = writeTimeout;
        this.readOrWriteTimeout = readOrWriteTimeout;
        this.handlerPrefix = StringUtils.isBlank(handlerPrefix) ? "" : handlerPrefix + "-";
    }

    public HeartbeatChannelInitializer() {
        this(DEFAULT_READ_TIMEOUT, DEFAULT_WRITE_TIMEOUT, DEFAULT_READ_WRITE_TIMEOUT, null);
    }

    public HeartbeatChannelInitializer(String handlerPrefix) {
        this(DEFAULT_READ_TIMEOUT, DEFAULT_WRITE_TIMEOUT, DEFAULT_READ_WRITE_TIMEOUT, handlerPrefix);
    }

    public HeartbeatChannelInitializer(int readOrWriteTimeout) {
        this(INT_ZERO, INT_ZERO, readOrWriteTimeout);
    }

    public HeartbeatChannelInitializer(int readOrWriteTimeout, String handlerPrefix) {
        this(INT_ZERO, INT_ZERO, readOrWriteTimeout, handlerPrefix);
    }

    public HeartbeatChannelInitializer(int readTimeout, int writeTimeout) {
        this(readTimeout, writeTimeout, DEFAULT_READ_WRITE_TIMEOUT, null);
    }

    public HeartbeatChannelInitializer(int readTimeout, int writeTimeout, String handlerPrefix) {
        this(readTimeout, writeTimeout, DEFAULT_READ_WRITE_TIMEOUT, handlerPrefix);
    }

    public HeartbeatChannelInitializer(int readTimeout, int writeTimeout, int readOrWriteTimeout) {
        this(readTimeout, writeTimeout, readOrWriteTimeout, null);
    }

    public HeartbeatChannelInitializer setReadTimeout(int readTimeout) {
        this.readTimeout = readTimeout;
        return this;
    }

    public HeartbeatChannelInitializer setWriteTimeout(int writeTimeout) {
        this.writeTimeout = writeTimeout;
        return this;
    }

    public HeartbeatChannelInitializer setReadOrWriteTimeout(int readOrWriteTimeout) {
        this.readOrWriteTimeout = readOrWriteTimeout;
        return this;
    }

    public HeartbeatChannelInitializer setHandlerPrefix(String name) {
        this.handlerPrefix = StringUtils.isBlank(name) ? "" : name + "-";
        return this;
    }

    public String getHandlerPrefix() {
        return handlerPrefix;
    }

    @Override
    protected void initChannel(SocketChannel socketChannel) {
        ChannelPipeline pipeline = socketChannel.pipeline();
        if (readOrWriteTimeout != DEFAULT_READ_WRITE_TIMEOUT) {
            pipeline.addLast(this.handlerPrefix + "idleStateHandler",
                    new IdleStateHandler(readTimeout, writeTimeout, readOrWriteTimeout, TimeUnit.SECONDS));
            pipeline.addLast(this.handlerPrefix + "idleStateEventHandler", new IdleStateEventHandler());
        } else {
            pipeline.addLast(this.handlerPrefix + "readTimeoutHandler", new ReadTimeoutHandler(readTimeout));
            pipeline.addLast(this.handlerPrefix + "writeTimeoutHandler", new WriteTimeoutHandler(writeTimeout));
            pipeline.addLast(this.handlerPrefix + "heartbeatHandler", new HeartbeatHandler());
        }
    }
}
package com.xxx.xxx.netty.handler.heartbeat;

import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.ReadTimeoutException;
import io.netty.handler.timeout.WriteTimeoutException;
import lombok.extern.slf4j.Slf4j;

/**
 * 心跳处理器
 */
@Slf4j
@ChannelHandler.Sharable
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
    /**
     * {@inheritDoc}
     */
    @Override
    public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
        log.info("{} 通道注册", ctx);

        ctx.channel().closeFuture()
                .addListener(listener -> {
                    if (listener.isSuccess()) {
                        log.info("{} 通道关闭", ctx);
                    }
                });
        super.channelRegistered(ctx);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        if (cause instanceof ReadTimeoutException) {
            log.warn("通道[{}]长时间未发生读取事件,客户端失去连接", ctx);
            ctx.channel().close();
        }
        if (cause instanceof WriteTimeoutException) {
            log.warn("通道[{}]长时间未发生写入事件,客户端失去连接", ctx);
            ctx.channel().close();
        }
    }
}
package com.xxx.xxx.netty.handler.heartbeat;

import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;

/**
 * 空闲事件处理器
 */
@Slf4j
@ChannelHandler.Sharable
public class IdleStateEventHandler extends ChannelDuplexHandler {
    /**
     * {@inheritDoc}
     */
    @Override
    public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
        log.info("{} 通道注册", ctx);

        ctx.channel().closeFuture()
                .addListener(listener -> {
                    if (listener.isSuccess()) {
                        log.info("{} 通道关闭", ctx);
                    }
                });
        super.channelRegistered(ctx);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object event) {
        if (event instanceof IdleStateEvent) {
            IdleStateEvent idleStateEvent = (IdleStateEvent) event;
            switch (idleStateEvent.state()) {
                case READER_IDLE:
                    log.warn("{} 读空闲,关闭通道", ctx);
                    ctx.channel().close();
                    break;
                case WRITER_IDLE:
                    log.warn("{} 写空闲,关闭通道", ctx);
                    ctx.channel().close();
                    break;
                case ALL_IDLE:
                    log.warn("{} 读|写空闲,关闭通道", ctx);
                    ctx.channel().close();
                    break;
            }
        }
    }
}

@ChannelHandler.Sharable 是 Netty 框架中的一个注解,它主要用于标记一个 ChannelHandler 实例是否可以被多个 ChannelPipeline 共享。

四、其他服务使用Netty服务

1. 使用Netty服务启动类

package xxxxxxxxxxxxxxxx;

import io.netty.channel.Channel;
import io.netty.channel.ChannelId;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

/**
 * xxx服务启动器
 *
 */
@Component
@EnableConfigurationProperties(XXXServerProperties.class)
public class XXXStarter {
    public static final TierConcurrentMap<ChannelId, Short, Channel> CHANNEL_MAP = new TierConcurrentMap<>();

    @Setter(onMethod_ = @Autowired)
    private XXXServerProperties xxxServerProperties;

    /**
     * 启动xxx服务端
     */
    @PostConstruct
    private void start() {
        // 启动xxx器服务端
        new Thread(() -> new NettyTCPServer("xxxxx", xxxServerProperties.getPort())
                .setChannelInitializer(new PagerChannelInitializer(xxxServerProperties.isOpenHeartbeat(), xxxServerProperties.getMaxFrameLength(), "xxxxx")
                        .setReadTimeout(xxxServerProperties.getReadTimeout())
                        .setWriteTimeout(0))
                .run())
                .start();
    }

    /**
     * xxx通道初始化器
     */
    private static class XXXChannelInitializer extends HeartbeatChannelInitializer {
        private final boolean openHeartbeat;

        private final int maxFrameLength;

        public XXXChannelInitializer(boolean openHeartbeat, int maxFrameLength, String name) {
            super(name);
            this.openHeartbeat = openHeartbeat;
            this.maxFrameLength = maxFrameLength;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected void initChannel(SocketChannel socketChannel) {
            socketChannel.pipeline().addLast(this.getHandlerPrefix() + "loggingHandler",
                    new LoggingHandler(LogLevel.INFO));
            socketChannel.pipeline().addLast(this.getHandlerPrefix() + "lengthFieldBasedFrameDecoder",
                    new LengthFieldBasedFrameDecoder(maxFrameLength, 4, 2, 0, 6));
            if (this.openHeartbeat) {
               super.initChannel(socketChannel);
            }
            socketChannel.pipeline().addLast(this.getHandlerPrefix() + "xxxInboundHandler",
                    new XXXInboundHandler());
            socketChannel.pipeline().addLast(this.getHandlerPrefix() + "xxxInstruct2ByteBufEncoder",
                    new XXXInstruct2ByteBufEncoder());
        }
    }
}
/**
 * xxx服务端配置
 */
@Setter
@Getter
@ConfigurationProperties(prefix = "xxx.app.xxx.server")
public class XXXServerProperties {
    /**
     * 服务端口号
     */
    private int port = 8083;

    /**
     * 是否打开心跳检测
     */
    private boolean openHeartbeat = true;

    /**
     * 读数据超时时间
     */
    private int readTimeout = 20;

    /**
     * 拆包时一帧数据最大的长度
     */
    private int maxFrameLength = 2048;
}

2. 使用Netty客户端启动类

package xxxxxxxxxxxxxxx;

import io.netty.channel.Channel;
import io.netty.channel.ChannelId;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * XXX客户端通道管理器
 */
@Slf4j
public class XXXClientChannelManage {
    /**
     * XXXID映射channel
     */
    public static Map<Long, Channel> MACHINE_CHANNEL_MAP = new ConcurrentHashMap<>();

    /**
     * channel id 映射XXXID
     */
    public static Map<ChannelId, Long> CHANNEL_MACHINE_MAP = new ConcurrentHashMap<>();

    /**
     * 事件循环组
     */
    public static EventLoopGroup eventLoopGroup = new NioEventLoopGroup();;

    /**
     * 获取XXX设备连接通道
     *
     * @param config XXX配置
     * @return 连接通道
     */
    public static Channel getConnectionChannel(XXXConfigDTO config) {
        try {
            Channel channel = XXXClientChannelManage.MACHINE_CHANNEL_MAP.get(config.getId());
            if (channel != null) {
                // 连接通道保持活性直接返回
                if (channel.isActive()) {
                    return channel;
                }

                // 连接通道失活则先移除池中该通道
                log.info("XXX[{}-{}]已连接但失活,准备重新打开连接", config.getXXXCode(), config.getXXXName());
                XXXClientChannelManage.CHANNEL_MACHINE_MAP.remove(channel.id());
                XXXClientChannelManage.MACHINE_CHANNEL_MAP.remove(config.getId());
            } else {
                log.info("XXX[{}-{}]未连接,准备打开连接", config.getXXXCode(), config.getXXXName());
            }

            Channel clientChannel = new NettyTCPClient(
                    config.getXXXIp(),
                    config.getXXXPort(),
                    new XXXInitializer(),
                    (listener) -> {
                        if (listener.isSuccess()) {
                            Channel closeChannel = XXXClientChannelManage.MACHINE_CHANNEL_MAP.get(config.getId());
                            if (closeChannel != null) {
                                XXXClientChannelManage.CHANNEL_MACHINE_MAP.remove(closeChannel.id());
                                XXXClientChannelManage.MACHINE_CHANNEL_MAP.remove(config.getId());
                            }
                        }
                    },
                    eventLoopGroup)
                    .run(false);

            if (clientChannel.isActive()) {
                XXXClientChannelManage.CHANNEL_MACHINE_MAP.put(clientChannel.id(), config.getId());
                XXXClientChannelManage.MACHINE_CHANNEL_MAP.put(config.getId(), clientChannel);
                return clientChannel;
            }

            throw new ManageException(CommonMessage.FAIL.getCode(),
                    "打开XXX[" + config.getXXXCode() + "-" + config.getXXXName() + "]连接失败");
        } catch (Exception e) {
            log.error("打开XXX[" + config.getXXXCode() + "-" + config.getXXXName() + "]连接失败", e);
            throw new ManageException(CommonMessage.FAIL.getCode(),
                    "打开XXX[" + config.getXXXCode() + "-" + config.getXXXName() + "]连接失败");
        }
    }
}

使用Netty客户端初始化特定客户端后,可以在业务代码中穿插使用,例如执行完业务逻辑代码后,需要通过Netty写入PLC,则可按以下模式实现:

            XXXClientChannelManage.getConnectionChannel(xxxConfig);
            XXXClientChannelManage.MACHINE_CHANNEL_MAP.get(xxxConfig.getId()).writeAndFlush(new ReadXXXRequest());

五、总结

通过以上步骤,已经成功开发了一个可被其他模块共用的独立 Netty 服务。在实际应用中,你可以根据具体需求对业务处理逻辑进行扩展,例如添加编解码器、实现更复杂的协议、初始化服务器或客户端时根据业务需求定制逻辑等。实际情况中可以根据PLC不同,走不同的初始化逻辑与读写等逻辑,希望这篇教学文章能帮助你更好地理解和使用 Netty 框架。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值