netty 终极篇 客户端,服务端双方通信,服务端自由发送。网络异常重新连接,tcp协议传输。(附带,写入,读取工具类txt,获取P配置文件工具类)

本文介绍了Netty的基础知识,包括IO与NIO的区别,并提供了完整的服务端和客户端示例。服务端通过Map记录客户端ID进行通信,并设有心跳设置和异常处理,当网络异常或心跳结束时,会从Map中移除对应连接。客户端具备网络异常自动重连功能,其配置信息存储在配置文件中。同时,文中还分享了ReadUtils和配置文件读取工具类。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

netty,io与nio的区别,先上代码在分析:
依赖包:

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

服务端:

import com.gs.util.ReadUtils;
import com.gs.util.YamlUtils;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;


@Slf4j
@Component
@EnableScheduling
public class NettyServer {

    public  static Map<String, ChannelHandlerContext> map = new HashMap<String, ChannelHandlerContext>();

    public static EventLoopGroup bossGroup;

    public static EventLoopGroup workerGroup;

    private static final int port = 9092;

    @Async
    @Scheduled(cron = "*/3 * * * * ?")
    public void run() throws Exception{
        // 记录netty 服务重启时间
        new YamlUtils().getRestar();
        //NioEventLoopGroup是用来处理IO操作的多线程事件循环器  用来接收进来的连接
        bossGroup  = new NioEventLoopGroup();
        // 用来处理已经被接收的连接
        workerGroup  = new NioEventLoopGroup();
        try{
            //是一个启动NIO服务的辅助启动类
            ServerBootstrap server =new ServerBootstrap();
            server.group(bossGroup,workerGroup )
                    .channel(NioServerSocketChannel.class)
                    // 这里告诉Channel如何接收新的连接
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            // 自定义处理类
                            ch.pipeline().addLast(new NettyServerFilter());
                        }
                    })
            ;
            // 具体参数介绍 https://blog.youkuaiyun.com/zhousenshan/article/details/72859923
            server.option(ChannelOption.SO_BACKLOG,128);
            // 保持长连接
            server.childOption(ChannelOption.SO_KEEPALIVE, true);
            //绑定端口,同步等待成功
            ChannelFuture future = server.bind(port).sync();
            ReadUtils.write("服务端启动成功...");
            // 监听服务器关闭监听
            future.channel().closeFuture().sync();
        }finally {
            //关闭EventLoopGroup,释放掉所有资源包括创建的线程
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }

    }

    public static void main(String[] args) throws Exception{
        new NettyServer().run();
    }
    

public static Map<String, ChannelHandlerContext> map = new
HashMap<String, ChannelHandlerContext>();

  • 这个很重要,是用来记录客户端的ID,也是通过它来像客户端发送信息。需要注意的是在心跳结束或者网络异常等导致连接断开时,需要异常 map 里对应的端口。 (代码中注释几乎都解释了…)

public static EventLoopGroup bossGroup;

public static EventLoopGroup workerGroup;

这两个这样写是为了在修改配置文件的时候方便关闭服务端。可能这样写有些不妥吧。

*代码中ReadUtils类是我用来记录netty 服务端的日志,等结尾会给大家都贴出来。

服务端的:心跳设置

import com.gs.util.YamlUtils;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler;

import java.util.concurrent.TimeUnit;

public class NettyServerFilter extends ChannelInitializer<SocketChannel>{

    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        // 配置文件
        Integer time = new YamlUtils().getCode("code2");
        // 心跳机制
        // readerIdleTime:读超时时间
        // writerIdleTime:写超时时间
        // allIdleTime:所有类型的超时时间
        ch.pipeline().addLast(new IdleStateHandler(0, time +1,0, TimeUnit.SECONDS));

        // 解码和编码,应和客户端一致
        ch.pipeline().addLast(new StringDecoder());
        ch.pipeline().addLast(new StringEncoder());

        //处理服务端的业务逻辑
        ch.pipeline().addLast(new HeartNettyServerHandler());
    }

*这边心跳设置有3个参数,服务端这边我永远是比客服端多出一秒的。也可以服务端和客户端直接都采用第三个参数,具体看注释吧。
服务端的业务逻辑

import com.gs.entity.result.ImeiResult;
import com.gs.service.BusinessService;
import com.gs.service.impl.GsNettyServiceImpl;
import com.gs.util.HttpUtils;
import com.gs.util.ReadUtils;
import com.gs.util.YamlUtils;
import io.netty.channel.*;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Resource;
import java.net.InetAddress;
import java.util.List;

@Slf4j
public class HeartNettyServerHandler  extends ChannelInboundHandlerAdapter {

    /**
     * 空闲次数
     */
    private int readIdleTimes = 1;

    /**
     * 发送次数
     */
    private int count = 1;

    /**
     * 超时处理,如果5秒没有收到客户端的心跳,就触发; 如果超过两次,则直接关闭;
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object obj) throws Exception {
        if (obj instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) obj;
            if (IdleState.READER_IDLE.equals(event.state())) { // 如果读通道处于空闲状态,说明没有接收到心跳命令
                if (readIdleTimes > 2) {
                    ReadUtils.write("超过两次无客户端请求,关闭该channel");
                    ctx.channel().close();
                }
                ReadUtils.write("已等待5秒还没收到客户端发来的消息");
                readIdleTimes ++;
            }
            if(readIdleTimes > 2){
                ReadUtils.write(" server : 读空闲超过2次,关闭连接 client:"+ ctx.channel().id());
                ctx.channel().writeAndFlush("Close the connection");
                ctx.channel().close();
            }
        } else {
            super.userEventTriggered(ctx, obj);
        }
    }

    /**
     * 业务逻辑处理
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ReadUtils.write("第" + count + "次" + ",服务端收到的消息:" + msg);

        String message = (String) msg;
        // 如果是心跳命令,服务端收到命令后回复一个相同的命令给客户端
        if ("hb_request".equals(message)) {
            ctx.channel().writeAndFlush("copy that the heartbeat");
            ctx.flush();
        }else{
            // 业务数据
            ReadUtils.write("开始处理业务");
            ReadUtils.write("报文总长: " + message);
            String channelId = ctx.channel().id().toString();
            if (NettyServer.map.get(channelId) != null && NettyServer.map.get(channelId).equals(ctx)) {
                ReadUtils.write("加入map通道");
            }else{
                ReadUtils.write("加入map通道");
                NettyServer.map.put(channelId,ctx);  
            }
        
        }
        count++;
    }

    /**
     * 异常处理
     * @param ctx
     * @param cause
     */
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        try{
            cause.printStackTrace();
        }catch (Exception e){
            e.printStackTrace();
            ReadUtils.write("netty 异常.."+e.getMessage());
        }finally {
            ctx.close();
        }
    }

    /**
     * 建立连接时,返回消息
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ReadUtils.write("连接的客户端地址:" + ctx.channel().remoteAddress());
        ReadUtils.write("连接的客户端ID:" + ctx.channel().id());
        ctx.writeAndFlush("client: "+ InetAddress.getLocalHost().getHostName() + "  success connected!");
        ReadUtils.write("connection in .. success");
        super.channelActive(ctx);
    }


    /**
     * 客户端主动断开服务端的链接,关闭流
     * */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        ReadUtils.write(ctx.channel().localAddress().toString() + " 通道不活跃!");
        removeChannnelMap(ctx);
        // 关闭流
        ctx.close();
    }

    /**
     * 删除map...
     * @param ctx
     */
    public void removeChannnelMap(ChannelHandlerContext ctx){
        String channelId = ctx.channel().id().toString();
        ChannelHandlerContext context = NettyServer.map.get(channelId);
        if( NettyServer.map.get(channelId) !=null && context.equals(ctx)){
            NettyServer.map.remove(channelId);
            ReadUtils.write("remove: "+channelId);
        }
       
    }

服务端结束。

客户端开始:

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Slf4j
@Component
@EnableScheduling
public class NettyClient {

    private static NioEventLoopGroup workGroup = new NioEventLoopGroup(4);

    private static Channel channel;

    private static Bootstrap bootstrap;

    public static void main(String[] args) {
        try {
            bootstrap = new Bootstrap();
            bootstrap
                    .group(workGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new NettyClientFilter());
            doConnect();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    //@Async
    //@Scheduled(cron = "*/4 * * * * ?")
    public void run(){
        try {
            bootstrap = new Bootstrap();
            bootstrap
                    .group(workGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new NettyClientFilter());
            doConnect();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    protected static void doConnect() {
        if (channel != null && channel.isActive()) {
            return;
        }

        ChannelFuture future = bootstrap.connect("localhost",9091);

        future.addListener(new ChannelFutureListener() {
            public void operationComplete(ChannelFuture futureListener) throws Exception {
                if (futureListener.isSuccess()) {
                    channel = futureListener.channel();
                    Channel channel = futureListener.sync().channel();
                    channel.writeAndFlush("Connect to server successfully!");
                } else {
                    log.info("Failed to connect to server, try connect after 10s");

                    futureListener.channel().eventLoop().schedule(new Runnable() {
                        @Override
                        public void run() {
                            doConnect();
                        }
                    }, 10, TimeUnit.SECONDS);
                }
            }
        });
    }

客户端这边也是比较全了,有网络异常,重新连接。博主的这种配置,都写在了配置文件里。最后都给大家贴出来。

客户端心跳设置

 @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        // 配置文件
        Integer time = new YamlUtils().getCode("code2");
        ChannelPipeline ph = ch.pipeline();
        //因为服务端设置的超时时间是5秒,所以客户端设置4秒
        ph.addLast(new IdleStateHandler(0, time, 0, TimeUnit.SECONDS));

        // 解码和编码,应和服务端一致
        ph.addLast(new StringDecoder());
        ph.addLast(new StringEncoder());

        //处理客户端的业务逻辑
        ph.addLast(new HeartNettyClientHandler());
    }

客户端业务:

import com.gs.util.DateUtils;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.CharsetUtil;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class HeartNettyClientHandler extends ChannelInboundHandlerAdapter {

    /**
     * 客户端请求的心跳命令
     */
    private static final ByteBuf HEARTBEAT_SEQUENCE =
            Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("hb_request", CharsetUtil.UTF_8));

    /**
     * 空闲次数
     */
    private int idle_count = 1;

    /**
     * 发送次数
     */
    private int count = 1;

    /**
     * 循环次数
     */
    private int cycle_count = 1;

    /**
     * 建立连接时
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        log.info("建立连接时:" + DateUtils.getTime());
        ctx.fireChannelActive();
    }

    /**
     * 关闭连接时
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        log.info("关闭连接时:" + DateUtils.getTime());
    }

    /**
     * 心跳请求处理,每4秒发送一次心跳请求
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object obj) throws Exception {
        log.info("\r\n循环请求的时间:" + DateUtils.getTime() + ",次数" + cycle_count);
        if (obj instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) obj;
            if (IdleState.WRITER_IDLE.equals(event.state())) { // 如果写通道处于空闲状态就发送心跳命令
                // 设置发送次数,允许发送3次心跳包
                if (idle_count <= 3) {
                    idle_count++;
                    ctx.channel().writeAndFlush(HEARTBEAT_SEQUENCE.duplicate());
                } else {
                    log.info("心跳包发送结束,不再发送心跳请求!!!");
                    ctx.close();
                }
            }
        }
        cycle_count ++;
    }

    /**
     * 业务逻辑处理
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 根据命令码执行......
        log.info("第" + count + "次" + ",客户端收到的消息:" + msg);
        count ++;
    }

}

在这里插入图片描述
贴入ReadUtils 工具类

import com.gs.common.DefContants;
import lombok.Cleanup;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StringUtils;

import java.io.*;

@Slf4j
public class ReadUtils {

    public static void read(){
        Resource resource = new ClassPathResource(DefContants.LOG_PATH);
        try{
            @Cleanup InputStream is = resource.getInputStream();
            @Cleanup InputStreamReader isr = new InputStreamReader(is);
            @Cleanup BufferedReader br = new BufferedReader(isr);
            String data = null;
            while((data = br.readLine()) != null) {
                System.out.println(data);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    /**
     * 日志
     * @param str
     */
    public static void write(String str) {
        String path = new ReadUtils().getClass().getClassLoader().getResource(DefContants.LOG_PATH).getPath();
        String key ="\n"+DateUtils.getTime()+":"+str;
        try {
            FileOutputStream fos = new FileOutputStream(new File(path),true);
            fos.write(key.getBytes());
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 原始透传数据只保留 20 条
     */
    public static  void clear(){
        Resource resource = new ClassPathResource(DefContants.PASSTHROUTH_PATH);
        try{
            @Cleanup InputStream is = resource.getInputStream();
            @Cleanup InputStreamReader isr = new InputStreamReader(is);
            @Cleanup BufferedReader br = new BufferedReader(isr);
            String data = null;
            StringBuffer buffer = new StringBuffer();
            int i = 0;
            while((data = br.readLine()) != null) {
                if(i != 0){
                    if(data != null){
                        buffer.append(data+",");
                    }
                }
                i ++;
            }
            String toString = buffer.toString();
            if(!StringUtils.isEmpty(toString)){
                String str = buffer.deleteCharAt(buffer.length() - 1).toString();
                String[] split = str.split(",");
                int length = split.length;
                if(length > 20){
                    String path = new ReadUtils().getClass().getClassLoader().getResource(DefContants.PASSTHROUTH_PATH).getPath();
                    String key ="原始数据透传 - 只保留 最新 20 条";
                    try {
                        FileOutputStream fos = new FileOutputStream(new File(path));
                        fos.write(key.getBytes());
                    } catch (FileNotFoundException e) {
                        e.printStackTrace();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    /**
     * 原始数据透传
     * @param str
     */
    public static void writeDate(String str) {
        clear();
        String path = new ReadUtils().getClass().getClassLoader().getResource(DefContants.PASSTHROUTH_PATH).getPath();
        String key ="\n"+DateUtils.getTime()+":"+str;
        try {
            FileOutputStream fos = new FileOutputStream(new File(path),true);
            fos.write(key.getBytes());
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }



}

此外在贴一个获取配置文件的公具类,可以修改,但需要注意的是修改后的文件并不会在原有基础上改变,而是在target/ 下

import com.alibaba.fastjson.JSONObject;
import com.gs.common.DefContants;
import com.sun.istack.Nullable;
import lombok.Cleanup;
import lombok.extern.slf4j.Slf4j;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;

import java.io.FileWriter;
import java.io.InputStream;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;

@Slf4j
public class YamlUtils {

    private final static DumperOptions OPTIONS = new DumperOptions();

    static{
        //设置yaml读取方式为块读取
        OPTIONS.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
        OPTIONS.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN);
        OPTIONS.setPrettyFlow(false);
    }

    /**
     * 将yaml配置文件转化成map
     * fileName 默认是resources目录下的yaml文件, 如果yaml文件在resources子目录下,需要加上子目录 比如:conf/config.yaml
     * @param fileName
     * @return
     */
    public static Map<String,Object> getYamlToMap(String fileName){
        LinkedHashMap<String, Object> yamls = new LinkedHashMap<>();
        Yaml yaml = new Yaml();
        try {
            @Cleanup InputStream in = YamlUtils.class.getClassLoader().getResourceAsStream(fileName);
            yamls = yaml.loadAs(in,LinkedHashMap.class);
        }catch (Exception e){
            log.error("{} load failed !!!" , fileName);
        }
        return yamls;
    }

    /**
     * key格式:aaa.bbb.ccc
     * 通过properties的方式获取yaml中的属性值
     * @param key
     * @param yamlMap
     * @return
     */
    public static Object getValue(String key, Map<String,Object> yamlMap){
        String[] keys = key.split("[.]");
        Object o = yamlMap.get(keys[0]);
        if(key.contains(".")){
            if(o instanceof Map){
                return getValue(key.substring(key.indexOf(".")+1),(Map<String,Object>)o);
            }else {
                return null;
            }
        }else {
            return o;
        }
    }

    /**
     * 使用递归的方式设置map中的值,仅适合单一属性
     * key的格式: "server.port"
     * server.port=111
     *
     **/
    public Map<String,Object> setValue(String key,Object value) {
        Map<String, Object> result = new LinkedHashMap<>();
        String[] keys = key.split("[.]");
        int i = keys.length - 1;
        result.put(keys[i], value);
        if (i > 0) {
            return setValue(key.substring(0, key.lastIndexOf(".")), result);
        }
        return result;
    }

    public static Map<String,Object> setValue(Map<String,Object> map, String key, Object value){

        String[] keys = key.split("\\.");

        int len = keys.length;
        Map temp = map;
        for(int i = 0; i< len-1; i++){
            if(temp.containsKey(keys[i])){
                temp = (Map)temp.get(keys[i]);
            }else {
                return null;
            }
            if(i == len-2){
                temp.put(keys[i+1],value);
            }
        }
        for(int j = 0; j < len - 1; j++){
            if(j == len -1){
                map.put(keys[j],temp);
            }
        }
        return map;
    }


    /**
     * 修改yaml中属性的值
     * @param key key是properties的方式: aaa.bbb.ccc (key不存在不修改)
     * @param value 新的属性值 (新属性值和旧属性值一样,不修改)
     * @param yamlName
     * @return true 修改成功,false 修改失败。
     */
    public static boolean updateYaml(String key, @Nullable Object value, String yamlName){

        Map<String, Object> yamlToMap = getYamlToMap(yamlName);
        if(null == yamlToMap) {
            return false;
        }
        Object oldVal = getValue(key, yamlToMap);

        //未找到key 不修改
        if(null == oldVal){
            log.error("{} key is not found",key);
            return false;
        }
        //不是最小节点值,不修改
        if(oldVal instanceof Map){
            log.error("input key is not last node {}",key);
            return false;
        }

        //新旧值一样 不修改
        if(value.equals(oldVal)){
            log.info("newVal equals oldVal, newVal: {} , oldVal: {}",value,oldVal);
            return false;
        }

        Yaml yaml = new Yaml(OPTIONS);
        YamlUtils utils = new YamlUtils();
        String path = utils.getClass().getClassLoader().getResource(yamlName).getPath();
        try {
            Map<String, Object> resultMap = setValue(yamlToMap, key, value);
            if(resultMap != null){
                yaml.dump(setValue(yamlToMap,key,value),new FileWriter(path));
                return true;
            }else {
                return false;
            }
        }catch (Exception e){
            log.error("yaml file update failed !");
            log.error("msg : {} ",e.getMessage());
            log.error("cause : {} ",e.getCause());
        }
        return false;
    }

    /**
     * 参数配置获取
     * @param str
     * @return
     */
    public  Integer getCode(String str){
        Map<String, Object> yamlToMap = this.getYamlToMap(DefContants.PC);
        String param = yamlToMap.get("param").toString().replace("=",":");
        JSONObject parseObject = JSONObject.parseObject(param);
        String code = parseObject.get(str).toString().replace(".",",");
        String time = code.split(",")[0];
        return  Integer.valueOf(time);
    }

    /**
     * 服务重启时间记录
     */
    public static void getRestar(){
        boolean b = updateYaml("netty.start", DateUtils.getTime(), DefContants.PC);
        log.info("netty 服务重启:"+b);
    }

    /**
     * 获取aes 加密密钥
     * @return
     */
    public static byte[] getAes(){
        Map<String, Object> yamlToMap = getYamlToMap(DefContants.PC);
        byte[] aes = yamlToMap.get("aes").toString().getBytes();
        return  aes;
    }

    /**
     * 配置参数修改次数
     */
    public static Integer getCount(){
        Map<String, Object> yamlToMap = getYamlToMap(DefContants.PC);
        String count = yamlToMap.get("count").toString();
        return  Integer.valueOf(count);
    }


    public static void main(String[] args) {
        Date date = new Date();
        YamlUtils configs = new YamlUtils();
        boolean b = new YamlUtils().updateYaml("netty.start", date.toString(), DefContants.PC);
        System.out.println(b);
        Map<String, Object> yamlToMap = configs.getYamlToMap(DefContants.PC);
        Object restart = yamlToMap.get("netty.start");
        System.out.println(restart);

    }

}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值