IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> SpringBoot + Netty 实现 TCP拆包粘包处理、TCP恶意连接拦截 -> 正文阅读

[Java知识库]SpringBoot + Netty 实现 TCP拆包粘包处理、TCP恶意连接拦截

作者:token annotation punctuation

================ 代码实现过程 ================

NettyServer:创建TCP服务

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;


/**
 * @author: Owen
 * @date: 2020/11/26
 * @description:TCP服务
 */
@Slf4j
public class NettyServer {
    private void startServer() {
        //初始化Netty线程池
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();
        
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workGroup);
            b.channel(NioServerSocketChannel.class);         //NIO非阻塞    
            b.option(ChannelOption.SO_BACKLOG, 1024);        //连接缓冲池的大小
            b.childOption(ChannelOption.TCP_NODELAY, true);  //关闭延迟发送
            b.childOption(ChannelOption.SO_KEEPALIVE, true); //维持链接的活跃,清除死链接
            b.childHandler(new DoorInitChannel());           //连接通道处理器

            //绑定端口,调用sync()方法来执行同步阻塞,直到绑定完成
            ChannelFuture sync = b.bind(9701).sync();

            //获取该Channel的CloseFuture,并且阻塞当前线程直到绑定的端口关闭才会执行关闭通道
            sync.channel().closeFuture().sync();
        } catch (Exception e) {
            log.error("TCP server init faild: "+e.getMessage();
            e.printStackTrace();
        } finally {
            cleanUp(bossGroup, workGroup);
        }
    }

    /**
	 * @author: Owen
	 * @date: 2020/11/26
	 * @description:清理Netty线程池
	 */
    private void cleanUp(EventLoopGroup bossGroup, EventLoopGroup workGroup) {
        bossGroup.shutdownGracefully();
        workGroup.shutdownGracefully();
    }

    /**
	 * @author: Owen
	 * @date: 2020/11/26
	 * @description:TCP服务初始化
	 */
    public void init() {
        new Thread(() -> {
            startServer();
        }).start();
    }

}

ChannelInit:通道连接事件

import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.timeout.IdleStateHandler;

/**
 * @author: Owen
 * @date: 2020/11/26
 * @description:连接通道初始化事件
 */
public class ChannelInit extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel channel){
        //TCP消息解码器 处理(拆包\粘包) 
        channel.pipeline().addLast("decoder", new BytePacketDecoder());

        //TCP连接活跃检测 60秒无活跃操作则 触发该事件
        channel.pipeline().addLast(new IdleStateHandler(60, 0, 0));

        //TCP事件监听、业务消息处理、连接\断开监听
        channel.pipeline().addLast("handler", new MessageHandler());
    }

}

NettyEvent :Netty连接事件 实体类

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author: Owen
 * @date: 2021/9/27
 * @description:Netty设备连接事件
 */
@Data
public class NettyEvent {

    //TCP建立连接时间
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date connectDate;

    //连接次数统计 原子整数类型 保证对数字的操作是线程安全
    private AtomicInteger connectCount;


    public NettyEvent() {

    }

    public NettyEvent(Date connectDate, AtomicInteger connectCount) {
        this.connectDate = connectDate;
        this.connectCount = connectCount;
    }
}

ClientEventManage :客户端事件管理

import com.google.common.cache.*;
import io.netty.channel.Channel;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Date;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author: Owen
 * @date: 2020/11/2
 * @description:TCP客户端事件管理
 */
@Slf4j
public class ClientEventManage {

    //TCP连接标识
    public static String CONNECT_CODE = "TCP_CONNECT_";

    //Netty连接事件缓存(记录TCP连接)
    private static LoadingCache<String, NettyEvent> connectEvent;
    

    /**
     * @Author: Owen
     * @Date: 2022/7/29
     * @Description:Netty连接校验(防止恶意连接)
     */
    public static void connectCheck(Channel ctx) {
        NettyEvent cahche = null;
        try {
            //客户端请求IP地址
            String clientIP = locationInfo(ctx);
            ExceptionUtil.isBlank(clientIP, "TCP client ip get is null !");

            //构建缓存Key
            String key = (CONNECT_CODE + clientIP);
            //查看当前IP 是否存在连接缓存
            cahche = queryConnectCache(key);
            //存在缓存
            if (!Objects.isNull(cahche)) {
                //连接统计 增量+1
                int connectCount = cahche.getConnectCount().incrementAndGet();
                log.info("TCP client iP:[" + getClientIp(ctx) + "] connect count:[" + connectCount + "], first connect time: " + DateUtil.getFormatTime(cahche.getConnectDate()));

                //60秒钟缓存失效之前,TCP请求连接限制30次
                if (30 <= connectCount) {
                    //连接统计 减量-1
                    cahche.getConnectCount().decrementAndGet();
                    log.error("TCP client iP:[" + getClientIp(ctx) + "] connect number exceed the 30 limit!");
                    //关闭本次 TCP连接请求
                    ctx.close();
                }
            }

            //不存在缓存
            else {
                //构建 连接缓存数据 记录当前时间,统计次数为1
                cahche = new NettyEvent(new Date(), new AtomicInteger(0));
                //设置连接缓存(默认60秒失效)
                connectEvent().put(key, cahche);
            }
        } catch (Exception e) {
            e.printStackTrace();
            throw new BusinessException("TCP connect check faild: " + e.getMessage());
        }
    }

    /**
     * @author: Owen
     * @date: 2021/9/25
     * @description:获取请求IP
     */
    public static String getClientIp(Channel ctx) {
        try {
            InetSocketAddress ipSocket = (InetSocketAddress) ctx.remoteAddress();
            InetAddress address = ipSocket.getAddress();
            StringBuffer value = new StringBuffer(address.getHostAddress());
            value.append(":");
            value.append(ipSocket.getPort());
            return value.toString();
        } catch (Exception e) {
            e.printStackTrace();
            log.error("Get tcp request location Faild: " + e.getMessage());
        }
        return null;
    }


    /**
     * @author: Owen
     * @date: 2021/9/25
     * @description:获取请求地址详情
     */
    public static String locationInfo(Channel ctx) {
        try {
            InetSocketAddress ipSocket = (InetSocketAddress) ctx.remoteAddress();
            InetAddress address = ipSocket.getAddress();
            StringBuffer value = new StringBuffer(address.getHostAddress());
            return value.toString();
        } catch (Exception e) {
            e.printStackTrace();
            log.error("Get tcp request location Faild: " + e.getMessage());
        }
        return null;
    }


    /**
     * @Author: Owen
     * @Date: 2022/7/29
     * @Description:查询连接缓存
     */
    public static NettyEvent queryConnectCache(String key) {
        NettyEvent cahceValue = null;
        try {
            cahceValue = connectEvent().get(key);
        } catch (Exception e) {
        }
        return cahceValue;
    }

    /**
     * @Author: Owen
     * @Date: 2022/7/29
     * @Description:TCP连接缓存(单例模式 懒加载)
     */
    public static LoadingCache<String, NettyEvent> connectEvent() {
        try {
            //第一次判空
            if (Objects.isNull(connectEvent)) {
                //保证线程安全
                synchronized (LoadingCache.class) {
                    //第二次判空,保证单例对象的唯一性,防止第一次有多个线程进入第一个if判断
                    if (Objects.isNull(connectEvent)) {
                        try {
                            //构建定时缓存
                            connectEvent = buildCache(new CacheLoader<String, NettyEvent>() {
                                @Override
                                public NettyEvent load(String key) {
                                    //值为null触发该事件
                                    return null;
                                }
                                //超过60秒 没有(读\写)操作,则自动清除
                            }, 60, 60);
                        } catch (Exception e) {
                            log.error("TCP connect cache, build faild: " + e.getMessage());
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.error("TCP connect cache exception" + e.getMessage());
        }
        return connectEvent;
    }

    /**
     * @author: Owen
     * @date: 2020/12/4
     * @description:构建定时缓存
     */
    private static LoadingCache<String, NettyEvent> buildCache(CacheLoader<String, NettyEvent> cacheLoader, long expireAfterAccess, long expireAfterWrite) {
        try {
            LoadingCache<String, NettyEvent> cache = CacheBuilder.newBuilder()
                    //10W容量大小,在缓存项接近该大小时, Guava开始回收旧的缓存项
                    .maximumSize(100000)
                    //活跃时间 设置时间对象没有被(读/写)访问,超过时间则从中删除(在另外的线程里面不定期维护)
                    .expireAfterAccess(expireAfterAccess, TimeUnit.SECONDS)
                    //失效时间 设置缓存在写入之后 缓存数据过期时间
                    .expireAfterWrite(expireAfterWrite, TimeUnit.SECONDS)
                    //移除监听器,缓存项 失效|| 被移除 会触发
                    .removalListener(new RemovalListener<String, NettyEvent>() {
                        @Override
                        public void onRemoval(RemovalNotification<String, NettyEvent> rn) {
                            //逻辑操作
//                            log.error("TCP connect cache :[" + rn.getKey() + "] timeout to remove!");
                        }
                    })
                    //开启Guava Cache的统计功能
                    .recordStats()
                    .build(cacheLoader);
            return cache;
        } catch (Exception e) {
            log.error("Loading cache build exception: " + e.getMessage());
            return null;
        }
    }

    /**
     * @author: Owen
     * @date: 2020/12/4
     * @description:时间转换
     */
       public static String getFormatTime(Date date) {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return simpleDateFormat.format(date);
    }
}

MessageHandler:消息处理器


/**
 * @author: Owen
 * @date: 2020/11/26
 * @description: TCP事件监听处理器
 */
@Slf4j
public class MessageHandler extends SimpleChannelInboundHandler<Object> {

    /**
     * @author: Owen
     * @date: 2021/9/25
     * @description: TCP连接注册事件(拦截连接请求)
     */
    @Override
    public void channelRegistered(ChannelHandlerContext ctx) {
        log.info("TCP client:" + ctx.channel() + " =========》》》》》request!");
        //TCP请求验证
        ClientEventManage.connectCheck(ctx.channel());
    }
    
    /**
     * @author: Owen
     * @date: 2021/9/25
     * @description: TCP连接事件(拦截连接成功)
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx){
        log.info("TCP client:" + ctx.channel() + " connect success!");
    }

    /**
     * @author: Owen
     * @date: 2021/9/25
     * @description: TCP断开连接(拦截连接断开)
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx){
       log.error("TCP client:" + ctx.channel() + " connect close!");
    }
    
    /**
     * @author: Owen
     * @date: 2021/9/24
     * @description: 消息监听(业务处理入口)
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
        try {
           log.info("TCP messge :"+msg);
        } catch (Exception e) {
            log.error("TCP message read exception: " + e.getMessage());
        } finally {
            //清理消息
            ReferenceCountUtil.release(msg);
        }
    }

    /**
     * @author: Owen
     * @date: 2021/9/24
     * @description: 连接活跃检测
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent e = (IdleStateEvent) evt;
            switch (e.state()) {
                //TCP连接 空闲检测(60秒无活跃操作则)
                case READER_IDLE:
                    log.error("TCP client:" + ctx.channel() + " connect lose efficacy!");
                    ctx.channel().close();
                    break;
                case WRITER_IDLE:
                    //handleWriterIdle(ctx);
                    break;
                case ALL_IDLE:
                    //handleAllIdle(ctx);
                    break;
                default:
                    break;
            }
        }
    }

     /**
     * @author: Owen
     * @date: 2021/9/25
     * @description: 消息结束之后时调用
     */
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

    /**
     * @author: Owen
     * @date: 2021/9/25
     * @description: 业务异常捕获
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
       log.error("TCP client:" + ctx.channel() + " business exceptions: "+ cause.getMessage());
    }
}

MessageDecoder :消息解码器

                                  TCP数据 拆包 粘包 详情图

在这里插入图片描述

import com.za.edu.bean.DataPacket;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;

/**
*@Author Owen
*@Date 2020/8/31
*@Description TCP消息解码器(处理粘包、拆包)
*/
public class MessageDecoder extends ByteToMessageDecoder {

   
	/**
	*@Author Owen
	*@Date 2020/8/31
	*@Description TCP消息解码
	*/
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf byteBuf, List<Object> out) throws Exception {
        byte[] head = null;             //(数据包(头)有效数据)
        Integer theLength = null;       //客户端 推送数据包的 总长度
        Integer bodyLength = null;      //客户端 推送数据的 内容长度
        Integer practicaLength = null;  //服务端 实际接收长度


        //数据解码(处理拆包、粘包)
        try {
            //标记当前指针位置
            byteBuf.markReaderIndex();
            //服务端 实际接收总长度
            practicaLength = byteBuf.readableBytes();
            //若服务端 实际接收总长度小于 数据头默认长度,则抛出异常
            ExceptionUtil.isTrue(practicaLength < ((数据包()有效长度)), "Total data length error");
            //遍历数据 验证当前可读数据中 数据头标识 是否存在
            boolean flag = false;
            //遍历所有字节
            while (byteBuf.isReadable()) {
                //判读是否是为 数据头标识(例如 数据包规则 以02开始 03结束)
                if (byteBuf.readByte() == (数据包()标识 02)) {
                    //表示已经找到 数据头02的字节
                    flag = true;
                    //若服务端 实际接收总长度 小于 默认 (头+尾)固定长度,则抛出异常
                    ExceptionUtil.isTrue(practicaLength < ((数据包()有效长度)+(数据包()有效长度)) - 1,
                            "Actual data length error!");
                    //找到开始位置 02,跳出循环
                    int index = (byteBuf.readerIndex() - 1);
                    //从开始位读取数据
                    byteBuf.readerIndex(index);
                    break;
                }
                //防止CPU飙高,执行线程睡眠10毫秒 释放当前线程资源
                Thread.sleep(10);
            }
            //是否获取到完整的数据包
            ExceptionUtil.isTrue(!flag, "Not found data head ! ");
            //数据头
            head = new byte[(数据包()默认有效长度)];
            //读取数据头
            byteBuf.readBytes(head);

            //获取消息的实际长度(业务不同 规则也不同)
            theLength = 根据TCP客户端 推送的消息,并解析获取到 当前数据包的实际长度;
            bodyLength= 根据TCP客户端 推送的消息,减去头尾长度 得到有效数据内容的长度;
            
            //服务端实际接收总长度 是否小于 客户端推送的数据的总长度,满足则抛出异常
            ExceptionUtil.isTrue(服务端 实际接收总长度 < theLength, "Total length is less than actual length ! ");
       
        } catch (Exception e) {
            //================= 数据包 出现拆包 =================
            //打印异常
            e.printStackTrace();
            //重置byteBuf读指针,等待后面的数据到达重组后 重新获取 
            byteBuf.resetReaderIndex();
            return;
        }

        //(数据包(内容)有效数据)
        byte[] body = null;
        //(数据包(尾)有效数据)
        byte[] foot = null;

        //服务端 当前接收数据的总长度 是否大于 客户端 推送的数据的总长度 (true 则表示数据出现 粘包)
        boolean ifAdhesion = (practicaLength > theLength);

        //数据内容 业务校验
        try {
            //=========== 取出 实际有效长度的数据,如果出现粘包数据,多出部分的数据也不会被取出
            //(数据包(内容)有效数据)
            body = new byte[bodyLength];
            //读取 内容
            byteBuf.readBytes(body);

            //(数据包(尾)有效数据)
            foot = new byte[数据包()默认长度];
            //读取 内容
            byteBuf.readBytes(foot);

            //根据(数据包(内容)有效数据)解析出业务数据
            Object message= 根据客户端消息业务规则,解析出对应的数据内容;

            ExceptionUtil.isNull(message, "TCP message get is null!");

            //写入数据 交给 (MessageHandler事件监听器) 处理业务逻辑
            out.add(tcpEvent);
        } catch (Exception e) {
         //================= 数据包出现(粘包) 或 业务数据验证不通过 =================
            //异常打印
            e.printStackTrace();
        } finally {
            //是否为粘包数据,若满足粘包,由于当前并未取出粘包部分的数据,如果清理byteBuf则会丢失下一个数据包
            if (!ifAdhesion) {
                //非粘包 则清除当前这次接收的 数据缓存
                byteBuf.clear();
            }
        }
    }
}

ExceptionUtil :异常工具类

import lombok.extern.slf4j.Slf4j;
import org.junit.platform.commons.util.StringUtils;
import org.springframework.util.CollectionUtils;
import java.util.Collection;
import java.util.regex.Pattern;


/**
* @Author: Owen
* @Date: 2022/7/25
* @Description:异常工具类
*/
@Slf4j
public class ExceptionUtil {
    public static void isTrue(Boolean boole, String msg) {
        if (boole) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isNotTrue(Boolean boole, String msg) {
        if (!boole) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }


    public static void isNull(Object obj, String msg) {
        if (obj == null) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isNotNull(Object obj, String msg) {
        if (obj != null) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isMatcher(String regex, String str, String msg) {
        if (!Pattern.matches(regex, str)) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isBlank(String obj, String msg) {
        if (StringUtils.isBlank(obj)) {
            log.debug(msg);
            new Exception(msg);
        }
    }

    public static void isEmpty(Collection value, String msg) {
        if (CollectionUtils.isEmpty(value)) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isEmpty(String str, String msg) {
        if(StringUtils.isBlank(str)) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isNotEmpty(Collection value, String msg) {
        if (!CollectionUtils.isEmpty(value)) {
            log.debug(msg);
            new Exception(msg);
        }
    }

    public static void isNotBlank(String value, String msg){
        if(StringUtils.isNotBlank(value)){
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }
    public static void isNonZero(Integer value, String msg) {
        if (! ((null == value) || (value == 0))) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }
    public static void isNonZero(Long value, String msg) {
        if (!((null == value) || (value == 0))) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }
    public static void isNonZero(Double value, String msg) {
        if (!((null == value) || (value == 0))) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }
    public static void isNullOrZero(Integer value, String msg) {
        if ((null == value) || (value == 0)) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isNullOrZero(Long value, String msg) {
        if ((null == value || value == 0)) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }

    public static void isNullOrZero(Double value, String msg) {
        if ((null == value) || (value == 0)) {
            log.debug(msg);
            new ExceptionManage(msg);
        }
    }
    /**
    * @Author: Owen
    * @Date: 2022/7/25
    * @Description:异常管理内部类
    */
   static class ExceptionManage extends RuntimeException {
        protected int code = 500;
        protected String msg;

        public ExceptionManage(int code,String msg) {
            super(msg);
            this.msg = msg;
            this.code = code;
        }


        public ExceptionManage(String msg) {
            super(msg);
            this.msg = msg;
        }

        public ExceptionManage() {
            this("服务器出了点意外...");
            this.msg = "服务器出了点意外...";
        }

        public ExceptionManage(Exception cause) {
            super(cause);
        }

        public ExceptionManage(String msg,Exception cause) {
            super(msg,cause);
            this.msg = msg;
        }

        public int getCode() {
            return code;
        }

        public void setCode(int code) {
            this.code = code;
        }

        public String getMsg() {
            return msg;
        }

        public void setMsg(String msg) {
            this.msg = msg;
        }

        @Override
        public String toString() {
            if(StringUtils.isNotBlank(msg)) {
                return "code:" + getCode() + ",msg:" + msg + ";";
            }
            return super.toString() + ";code:" + getCode();
        }

    }
}

模拟TCP恶意连接 测试结果

Jmeter模拟TCP连接,向我们TCP服务异步发起120个连接
在这里插入图片描述
测试结果:
只根据IP来做校验 不带端口号,因为客户端发起的 每一次TCP连接端口号都不同!
在这里插入图片描述

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-08-06 10:29:56  更:2022-08-06 10:33:58 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 13:04:27-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码