SpringBoot + Netty + Vue + WebSocket实现在线聊天

news2025/4/18 18:42:32

最近想学学WebSocket做一个实时通讯的练手项目

主要用到的技术栈是WebSocket Netty Vue Pinia MySQL SpringBoot,实现一个持久化数据,单一群聊,支持多用户的聊天界面

下面是实现的过程

后端

SpringBoot启动的时候会占用一个端口,而Netty也会占用一个端口,这两个端口不能重复,并且因为Netty启动后会阻塞当前线程,因此需要另开一个线程防止阻塞住SpringBoot

1. 编写Netty服务器

个人认为,Netty最关键的就是channel,可以代表一个客户端

我在这使用的是@PostConstruct注解,在Bean初始化后调用里面的方法,新开一个线程运行Netty,因为希望Netty受Spring管理,所以加上了spring的注解,也可以直接在启动类里注入Netty然后手动启动

@Service
public class NettyService {
    private EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    private EventLoopGroup workGroup = new NioEventLoopGroup();
    @Autowired
    private WebSocketHandler webSocketHandler;
    @Autowired
    private HeartBeatHandler heartBeatHandler;
    @PostConstruct
    public void initNetty() throws BaseException {
        new Thread(()->{
            try {
                start();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }).start();
    }
    @PreDestroy
    public void destroy() throws BaseException {
        bossGroup.shutdownGracefully();
        workGroup.shutdownGracefully();
    }
    @Async
    public void start() throws BaseException {
        try {
            ChannelFuture channelFuture = new ServerBootstrap()
                    .group(bossGroup, workGroup)
                    .channel(NioServerSocketChannel.class)
                    .handler(new LoggingHandler(LogLevel.DEBUG))
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        @Override
                        protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {
                            nioSocketChannel.pipeline()
// http解码编码器
                                    .addLast(new HttpServerCodec())
// 处理完整的 HTTP 消息
                    .addLast(new HttpObjectAggregator(64 * 1024))
// 心跳检测时长
                                    .addLast(new IdleStateHandler(300, 0, 0, TimeUnit.SECONDS))
// 心跳检测处理器
                                    .addLast(heartBeatHandler)
// 支持ws协议(自定义)
                                    .addLast(new WebSocketServerProtocolHandler("/ws",null,true,64*1024,true,true,10000))
// ws请求处理器(自定义)
                                    .addLast(webSocketHandler)
                            ;
                        }
                    }).bind(8081).sync();
            System.out.println("Netty启动成功");
            ChannelFuture future = channelFuture.channel().closeFuture().sync();
        }
        catch (InterruptedException e){
            throw new InterruptedException ();
        }
        finally {
//优雅关闭
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }

    }
}

服务器类只是指明一些基本信息,包含处理器类,支持的协议等等,具体的处理逻辑需要再自定义类来实现

2. 心跳检测处理器

心跳检测是指 服务器无法主动确定客户端的状态(用户可能关闭了网页,但是服务端没办法知道),为了确定客户端是否在线,需要客户端定时发送一条消息,消息内容不重要,重要的是发送消息代表该客户端仍然在线,当客户端长时间没有发送数据时,代表客户端已经下线

package org.example.payroll_management.websocket.netty.handler;

@Component
@ChannelHandler.Sharable
public class HeartBeatHandler extends ChannelDuplexHandler {

    @Autowired
    private ChannelContext channelContext;
    private static final Logger logger =  LoggerFactory.getLogger(HeartBeatHandler.class);

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent){
            // 心跳检测超时
            IdleStateEvent e = (IdleStateEvent) evt;
            logger.info("心跳检测超时");
            if (e.state() == IdleState.READER_IDLE){
                Attribute<Integer> attr = ctx.channel().attr(AttributeKey.valueOf(ctx.channel().id().toString()));
                Integer userId = attr.get();
                // 读超时,当前已经下线,主动断开连接
                ChannelContext.removeChannel(userId);
                ctx.close();
            } else if (e.state() == IdleState.WRITER_IDLE){
                ctx.writeAndFlush("心跳检测");
            }
        }
        super.userEventTriggered(ctx, evt);
    }
}

3. webSocket处理器

当客户端发送消息,消息的内容会发送当webSocket处理器中,可以对对应的方法进行处理,我这里偷懒了,就做了一个群组,全部用户只能在同一群中聊天,不过创建多个群组,或单对单聊天也不复杂,只需要将群组的ID进行保存就可以

这里就产生第一个问题了,就是SpringMVC的拦截器不会拦截其他端口的请求,解决方法是将token放置到请求参数中,在userEventTriggered方法中重新进行一次token检验

第二个问题,我是在拦截器中通过ThreadLocal保存用户ID,不走拦截器在其他地方拿不到用户ID,解决方法是,在userEventTriggered方法中重新保存,或者channel中可以保存附件(自身携带的数据),直接将id保存到附件中

第三个问题,消息的持久化,当用户重新打开界面时,肯定希望消息仍然存在,鉴于webSocket的实时性,数据持久化肯定不能在同一个线程中完成,我在这使用BlockingQueue+线程池完成对消息的异步保存,或者也可以用mq实现

不过用的Executors.newSingleThreadExecutor();可能会产生OOM的问题,后面可以自定义一个线程池,当任务满了之后,指定拒绝策略为抛出异常,再通过全局异常捕捉拿到对应的数据保存到数据库中,不过俺这种小项目应该不会产生这种问题

第四个问题,消息内容,这个需要前后端统一一下,确定一下传输格式就OK了,然后从JSON中取出数据处理

最后就是在线用户统计,这个没什么好说的,里面有对应的方法,当退出时,直接把channel踢出去就可以了

package org.example.payroll_management.websocket.netty.handler;

@Component
@ChannelHandler.Sharable
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    @Autowired
    private ChannelContext channelContext;
    @Autowired
    private MessageMapper messageMapper;
    @Autowired
    private UserService userService;
    private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);

    private static final BlockingQueue<WebSocketMessageDto> blockingQueue = new ArrayBlockingQueue(1024 * 1024);
    private static final ExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadExecutor();
    // 提交线程
    @PostConstruct
    private void init(){
        EXECUTOR_SERVICE.submit(new MessageHandler());
    }
    private class MessageHandler implements Runnable{
        // 异步保存
        @Override
        public void run() {
            while(true){
                WebSocketMessageDto message = null;
                try {
                    message = blockingQueue.take();
                    logger.info("消息持久化");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                Integer success = messageMapper.saveMessage(message);
                if (success < 1){
                    try {
                        throw new BaseException("保存信息失败");
                    } catch (BaseException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }

    }
    // 当读事件发生时(有客户端发送消息)
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
        Channel channel = channelHandlerContext.channel();
        // 收到的消息
        String text = textWebSocketFrame.text();
        Attribute<Integer> attr = channelHandlerContext.channel().attr(AttributeKey.valueOf(channelHandlerContext.channel().id().toString()));
        Integer userId = attr.get();
        logger.info("接收到用户ID为 {} 的消息: {}",userId,text);
        // TODO  将text转成JSON,提取里面的数据
        WebSocketMessageDto webSocketMessage = JSONUtil.toBean(text, WebSocketMessageDto.class);
        if (webSocketMessage.getType().equals("心跳检测")){
            logger.info("{}发送心跳检测",userId);
        }

        else if (webSocketMessage.getType().equals("群发")){

            ChannelGroup channelGroup = ChannelContext.getChannelGroup(null);
            WebSocketMessageDto messageDto = JSONUtil.toBean(text, WebSocketMessageDto.class);

            WebSocketMessageDto webSocketMessageDto = new WebSocketMessageDto();
            webSocketMessageDto.setType("群发");
            webSocketMessageDto.setText(messageDto.getText());
            webSocketMessageDto.setReceiver("all");
            webSocketMessageDto.setSender(String.valueOf(userId));
            webSocketMessageDto.setSendDate(TimeUtil.timeFormat("yyyy-MM-dd"));

            blockingQueue.add(webSocketMessageDto);
            channelGroup.writeAndFlush(new TextWebSocketFrame(JSONUtil.toJsonPrettyStr(webSocketMessageDto)));
        }
        else{
            channel.writeAndFlush("请发送正确的格式");
        }
    }

    // 建立连接后触发(有客户端建立连接请求)
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        logger.info("建立连接");
        super.channelActive(ctx);
    }

    // 连接断开后触发(有客户端关闭连接请求)
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {

        Attribute<Integer> attr = ctx.channel().attr(AttributeKey.valueOf(ctx.channel().id().toString()));
        Integer userId = attr.get();
        logger.info("用户ID:{} 断开连接",userId);

        ChannelGroup channelGroup = ChannelContext.getChannelGroup(null);
        channelGroup.remove(ctx.channel());
        ChannelContext.removeChannel(userId);

        WebSocketMessageDto webSocketMessageDto = new WebSocketMessageDto();
        webSocketMessageDto.setType("用户变更");
        List<OnLineUserVo> onlineUser = userService.getOnlineUser();
        webSocketMessageDto.setText(JSONUtil.toJsonStr(onlineUser));
        webSocketMessageDto.setReceiver("all");
        webSocketMessageDto.setSender("0");
        webSocketMessageDto.setSendDate(TimeUtil.timeFormat("yyyy-MM-dd"));
        channelGroup.writeAndFlush(new TextWebSocketFrame(JSONUtil.toJsonStr(webSocketMessageDto)));
        super.channelInactive(ctx);
    }

// 建立连接后触发(客户端完成连接)
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete){
            WebSocketServerProtocolHandler.HandshakeComplete handshakeComplete = (WebSocketServerProtocolHandler.HandshakeComplete) evt;
            String uri = handshakeComplete.requestUri();
            logger.info("uri: {}",uri);
            String token = getToken(uri);
            if (token == null){
                logger.warn("Token校验失败");
                ctx.close();
                throw new BaseException("Token校验失败");
            }
            logger.info("token: {}",token);

            Integer userId = null;
            try{
                Claims claims = JwtUtil.extractClaims(token);
                userId = Integer.valueOf((String) claims.get("userId"));
            }catch (Exception e){
                logger.warn("Token校验失败");
                ctx.close();
                throw new BaseException("Token校验失败");
            }
            // 向channel中的附件中添加用户ID
            channelContext.addContext(userId,ctx.channel());
            ChannelContext.setChannel(userId,ctx.channel());
            ChannelContext.setChannelGroup(null,ctx.channel());

            ChannelGroup channelGroup = ChannelContext.getChannelGroup(null);

            WebSocketMessageDto webSocketMessageDto = new WebSocketMessageDto();
            webSocketMessageDto.setType("用户变更");
            List<OnLineUserVo> onlineUser = userService.getOnlineUser();
            webSocketMessageDto.setText(JSONUtil.toJsonStr(onlineUser));
            webSocketMessageDto.setReceiver("all");
            webSocketMessageDto.setSender("0");
            webSocketMessageDto.setSendDate(TimeUtil.timeFormat("yyyy-MM-dd"));
            channelGroup.writeAndFlush(new TextWebSocketFrame(JSONUtil.toJsonStr(webSocketMessageDto)));

        }
        super.userEventTriggered(ctx, evt);
    }
    private String getToken(String uri){
        if (uri.isEmpty()){
            return null;
        }
        if(!uri.contains("token")){
            return null;
        }
        String[] split = uri.split("\\?");
        if (split.length!=2){
            return null;
        }
        String[] split1 = split[1].split("=");
        if (split1.length!=2){
            return null;
        }
        return split1[1];
    }
}

4. 工具类

主要用来保存用户信息的

不要问我为什么又有static又有普通方法,问就是懒得改,这里我直接保存的同一个群组,如果需要多群组的话,就需要建立SQL数据了

package org.example.payroll_management.websocket;

@Component
public class ChannelContext {

    private static final Map<Integer, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
    private static final Map<Integer, ChannelGroup> USER_CHANNELGROUP_MAP = new ConcurrentHashMap<>();
    private static final Integer GROUP_ID = 10086;

    private static final Logger logger = LoggerFactory.getLogger(ChannelContext.class);

    public void addContext(Integer userId,Channel channel){
        String channelId = channel.id().toString();
        AttributeKey attributeKey = null;
        if (AttributeKey.exists(channelId)){
            attributeKey = AttributeKey.valueOf(channelId);
        } else{
            attributeKey = AttributeKey.newInstance(channelId);
        }
        channel.attr(attributeKey).set(userId);
    }
    public static List<Integer> getAllUserId(){
        return new ArrayList<>(USER_CHANNEL_MAP.keySet());
    }
    public static void setChannel(Integer userId,Channel channel){
        USER_CHANNEL_MAP.put(userId,channel);
    }

    public static Channel getChannel(Integer userId){
        return USER_CHANNEL_MAP.get(userId);
    }
    public static void removeChannel(Integer userId){
        USER_CHANNEL_MAP.remove(userId);
    }

    public static void setChannelGroup(Integer groupId,Channel channel){
        if(groupId == null){
            groupId = GROUP_ID;
        }
        ChannelGroup channelGroup = USER_CHANNELGROUP_MAP.get(groupId);
        if (channelGroup == null){
            channelGroup =new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
            USER_CHANNELGROUP_MAP.put(GROUP_ID, channelGroup);
        }
        if (channel == null){
            return ;
        }
        channelGroup.add(channel);
        logger.info("向group中添加channel,ChannelGroup已有Channel数量:{}",channelGroup.size());

    }

    public static ChannelGroup getChannelGroup(Integer groupId){
        if (groupId == null){
            groupId = GROUP_ID;
        }
        return USER_CHANNELGROUP_MAP.get(groupId);
    }
    public static void removeChannelGroup(Integer groupId){
        if (groupId == null){
            groupId = GROUP_ID;
        }
         USER_CHANNELGROUP_MAP.remove(groupId);
    }
}

写到这里,Netty服务就搭建完成了,后面就可以等着前端的请求建立了

前端

前端我使用的vue,因为我希望当用户登录后自动建立ws连接,所以我在登录成功后添加上了ws建立请求,然后我发现,如果用户关闭网页后重新打开,因为跳过了登录界面,ws请求不会自动建立,所以需要一套全局的ws请求

不过我前端不是很好(其实后端也一般),所以很多地方肯定有更优的写法

1. pinia

使用pinia保存ws请求,方便在其他组件中调用

定义WebSocket实例(ws)和一个请求建立判断(wsConnect)

后面就可以通过ws接收服务的消息

import { defineStore } from 'pinia'

export const useWebSocketStore = defineStore('webSocket', {

    state() {
        return {
            ws: null,
            wsConnect: false,
        }
    },
    actions: {
        wsInit() {
            if (this.ws === null) {
                const token = localStorage.getItem("token")
                if (token === null)  return;
                this.ws = new WebSocket(`ws://localhost:8081/ws?token=${token}`)
                  
                this.ws.onopen = () => {
                    this.wsConnect = true;
                    console.log("ws协议建立成功")
                    // 发送心跳
                    const intervalId = setInterval(() => {
                        if (!this.wsConnect) {
                            clearInterval(intervalId)
                        }
                        const webSocketMessageDto = {
                            type: "心跳检测"
                        }
                        this.sendMessage(JSON.stringify(webSocketMessageDto));
                    }, 1000 * 3 * 60);
                }
                this.ws.onclose = () => {
                    this.ws = null;
                    this.wsConnect = false;
                }
            }
        },
        sendMessage(message) {
            if (message == null || message == '') {
                return;
            }
            if (!this.wsConnect) {
                console.log("ws协议没有建立")
                this.wsInit();
            }
            this.ws.send(message);
        },
        wsClose() {
            if (this.wsConnect) {
                this.ws.close();
                this.wsConnect = false;
            }
        }
    }
})

然后再app.vue中循环建立连接(建立请求重试)

 const wsConnect = function () {
        const token = localStorage.getItem("token")
        if (token === null) {
            return;
        }
        try {
            if (!webSocket.wsConnect) {
                console.log("尝试建立ws请求")
                webSocket.wsInit();
            } else {
                return;
            }
        } catch {
            wsConnect();
        }
    }

2. 聊天组件

界面相信大伙都会画,主要说一下我遇到的问题

第一个 上拉刷新,也就是加载历史记录的功能,我用的element-plus UI,也不知道是不是我的问题,UI里面的无限滚动不是重复发送请求就是无限发送请求,而且好像没有上拉加载的功能。于是我用了IntersectionObserver来解决,在页面底部加上一个div,当观察到这个div时,触发请求

第二个 滚动条到达顶部时,请求数据并放置数据,滚动条会自动滚动到顶部,并且由于观察的元素始终在顶端导致无限请求,这个其实也不是什么大问题,因为聊天的消息是有限的,没有数据之后我设置了停止观察,主要是用户体验不是很好。这是我是添加了display: flex; flex-direction: column-reverse;解决这个问题的(flex很神奇吧)。大致原理好像是垂直翻转了(例如上面我将观察元素放到div第一个子元素位置,添加flex后观察元素会到最后一个子元素位置上),也就是说当滚动条在最底部时,添加数据后,滚动条会自动滚动到最底部,不过这样体验感非常的不错

不要问我为什么数据要加 || 问就是数据懒得统一了

<style lang="scss" scoped>
    .chatBox {
        border-radius: 20px;
        box-shadow: rgba(0, 0, 0, 0.05) -2px 0px 8px 0px;
        width: 1200px;
        height: 600px;
        background-color: white;
        display: flex;

        .chat {
            width: 1000px;
            height: inherit;

            .chatBackground {
                height: 500px;
                overflow: auto;
                display: flex;
                flex-direction: column-reverse;

                .loading {
                    text-align: center;
                    font-size: 12px;
                    margin-top: 20px;
                    color: gray;

                }

                .chatItem {
                    width: 100%;
                    padding-bottom: 20px;

                    .avatar {
                        margin-left: 20px;
                        display: flex;
                        align-items: center;

                        .username {
                            margin-left: 10px;
                            color: rgb(153, 153, 153);
                            font-size: 13px;
                        }
                    }

                    .chatItemMessage {
                        margin-left: 60px;
                        padding: 10px;
                        font-size: 14px;
                        width: 200px;
                        word-break: break-all;
                        max-width: 400px;
                        line-height: 25px;
                        width: fit-content;
                        border-radius: 10px;
                        height: auto;
                        /* background-color: skyblue; */
                        box-shadow: rgba(0, 0, 0, 0.05) -2px 0px 8px 0px;
                    }

                    .sendDate {
                        font-size: 12px;
                        margin-top: 10px;
                        margin-left: 60px;
                        color: rgb(187, 187, 187);
                    }
                }
            }

            .chatBottom {
                height: 100px;
                background-color: #F3F3F3;
                border-radius: 20px;
                display: flex;
                box-shadow: rgba(0, 0, 0, 0.05) -2px 0px 8px 0px;

                .messageInput {
                    border-radius: 20px;
                    width: 400px;
                    height: 40px;
                }

            }

        }

        .userList {
            width: 200px;
            height: inherit;
            border-radius: 20px;
            box-shadow: rgba(0, 0, 0, 0.05) -2px 0px 8px 0px;

            .user {
                width: inherit;
                height: 50px;
                line-height: 50px;
                text-indent: 2em;
                border-radius: 20px;
                transition: all 0.5s ease;
            }
        }
    }

    .user:hover {
        box-shadow: rgba(0, 0, 0, 0.05) -2px 0px 8px 0px;
        transform: translateX(-5px) translateY(-5px);
    }
</style>

<template>
    {{hasMessage}}
    <div class="chatBox">
        <div class="chat">
            <div class="chatBackground" ref="chatBackgroundRef">

                <div class="chatItem" v-for="i in messageList">
                    <div class="avatar">
                        <el-avatar :size="40" :src="imageUrl" />
                        <div class="username">{{i.username || i.userId}}</div>
                    </div>
                    <div class="chatItemMessage">
                        {{i.text || i.content}}
                    </div>
                    <div class="sendDate">
                        {{i.date || i.sendDate}}
                    </div>

                </div>
                <div class="loading" ref="loading">
                    显示更多内容
                </div>
            </div>
            <div class="chatBottom">
                <el-input class="messageInput" v-model="message" placeholder="消息内容"></el-input>
                <el-button @click="sendMessage">发送消息</el-button>
            </div>
        </div>
        <!-- 做成无限滚动 -->
        <div class="userList">
            <div v-for="user in userList">
                <div class="user">
                    {{user.userName}}
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
    import { ref, onMounted, nextTick } from 'vue'
    import request from '@/utils/request.js'
    import { useWebSocketStore } from '@/stores/useWebSocketStore'
    import imageUrl from '@/assets/默认头像.jpg'


    const webSocketStore = useWebSocketStore();
    const chatBackgroundRef = ref(null)

    const userList = ref([])
    const message = ref('')
    const messageList = ref([
    ])
    const loading = ref(null)
    const page = ref(1);
    const size = 10;
    const hasMessage = ref(true);

    const observer = new IntersectionObserver((entries, observer) => {
        entries.forEach(async entry => {
            if (entry.isIntersecting) {
                observer.unobserve(entry.target)
                await pageQueryMessage();

            }
        })
    })

    onMounted(() => {
        observer.observe(loading.value)
        getOnlineUserList();
        if (!webSocketStore.wsConnect) {
            webSocketStore.wsInit();
        }
        const ws = webSocketStore.ws;
        ws.onmessage = async (e) => {
            // console.log(e);
            const webSocketMessage = JSON.parse(e.data);
            const messageObj = {
                username: webSocketMessage.sender,
                text: webSocketMessage.text,
                date: webSocketMessage.sendDate,
                type: webSocketMessage.type
            }
            console.log("###")
            // console.log(JSON.parse(messageObj.text))
            if (messageObj.type === "群发") {
                messageList.value.unshift(messageObj)
            } else if (messageObj.type === "用户变更") {
                userList.value = JSON.parse(messageObj.text)
            }
            await nextTick();
            // 当发送新消息时,自动滚动到页面最底部,可以替换成消息提示的样式
            // chatBackgroundRef.value.scrollTop = chatBackgroundRef.value.scrollHeight;
            console.log(webSocketMessage)
        }
    })
    const pageQueryMessage = function () {
        request({
            url: '/api/message/pageQueryMessage',
            method: 'post',
            data: {
                page: page.value,
                size: size
            }
        }).then((res) => {
            console.log(res)
            if (res.data.data.length === 0) {
                hasMessage.value = false;
            }
            else {
                observer.observe(loading.value)
                page.value = page.value + 1;
                messageList.value.push(...res.data.data)
            }
        })
    }
    function getOnlineUserList() {
        request({
            url: '/api/user/getOnlineUser',
            method: 'get'
        }).then((res) => {
            console.log(res)
            userList.value = res.data.data;
        })
    }

    const sendMessage = function () {
        if (!webSocketStore.wsConnect) {
            webSocketStore.wsInit();
        }
        const webSocketMessageDto = {
            type: "群发",
            text: message.value
        }

        webSocketStore.sendMessage(JSON.stringify(webSocketMessageDto));
    }

</script>

这样就实现了一个简易的聊天数据持久化,支持在线聊天的界面,总的来说WebSocket用起来还是十分方便的

后面我看看能不能做下上传图片,上传文件之类的功能

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2332251.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

配置mac mini M4 的一些软件

最近更换了 mac mini M4 &#xff0c;想要重新下载配置软件 &#xff0c;记录一下。 Homebrew是什么&#xff1f; homebrew是一款Mac OS平台下的软件包管理工具&#xff0c;拥有安装、卸载、更新、查看、搜索等功能。通过简单的指令可以实现包管理&#xff0c;而不用关心各种…

Java——抽象方法抽象类 接口 详解及综合案例

1.抽象方法抽象类 介绍 抽象方法: 将共性的行为(方法)抽取到父类之后&#xff0c; 由于每一个子类执行的内容是不一样&#xff0c; 所以&#xff0c;在父类中不能确定具体的方法体。 该方法就可以定义为抽象方法。 抽象类: 如果一个类中存在抽象方法&#xff0c;那么该类就必须…

【计网】一二章习题

1. (单选题, 3 分) 假设主机A和B之间的链路带宽为100Mbps&#xff0c;主机A的网卡速率为1Gbps&#xff0c;主机B的网卡速率为10Mbps&#xff0c;主机A给主机B发送数据的最高理论速率为&#xff08; &#xff09;。 A. 100Mbps B. 1Gbps C. 1Mbps D. 10Mbps 正确答案 D 发…

【软考-高级】【信息系统项目管理师】【论文基础】进度管理过程输入输出及工具技术的使用方法

定义 项目进度管理是为了保证项目按时完成&#xff0c;对项目中所需的各个过程进行管理的过程&#xff0c;包括规划进度、定义活动、活动优先级排序、活动持续时间、制定进度计划和控制进度。 管理基础 制定进度计划的一般步骤 选择进度计划方法&#xff08;如关键路径法&a…

TOGAF之架构标准规范-技术架构

TOGAF是工业级的企业架构标准规范&#xff0c;本文主要描述技术架构阶段。 如上所示&#xff0c;技术架构&#xff08;Technology Architecture&#xff09;在TOGAF标准规范中处于D阶段 技术架构阶段 技术架构阶段的主要内容包括阶段目标、阶段输入、流程步骤、阶段输出、架构…

Ansys Electronics 变压器 ACT

你好&#xff0c; 在本博客中&#xff0c;我将讨论如何使用 Ansys 电子变压器 ACT 自动快速地设计电力电子电感器或变压器。我将逐步介绍设计和创建电力电子变压器示例的步骤&#xff0c;该变压器为同心组件&#xff0c;双绕组&#xff0c;采用正弦电压激励&#xff0c;并应用…

十三种物联网/通信模块综合对比——《数据手册--物联网/通信模块》

物联网&#xff0f;通信模块 名称 功能 应用场景 USB转换模块 用于将USB接口转换为其他类型的接口&#xff0c;如串口、并口等&#xff0c;实现不同设备之间的通信。 常用于计算机与外部设备&#xff08;如打印机、扫描仪等&#xff09;的连接&#xff0c;以及数据传输和设…

Redis安装(Windows环境)

文章目录 Resid简介:下载Redis启动Redis服务设置Windows服务常用的Redis服务命令 Resid简介: Redis 是一个开源的使用 ANSI C 语言编写、遵守 BSD 协议、支持网络、可基于内存、分布式、可选持久性的键值对(Key-Value)存储数据库&#xff0c;并提供多种语言的 API。 Redis通常…

FreeRTOS项目工程完善指南:STM32F103C8T6系列

FreeRTOS项目工程完善指南&#xff1a;STM32系列 本文是FreeRTOS STM32开发系列教程的一部分。我们将完善之前移植的FreeRTOS工程&#xff0c;添加串口功能并优化配置文件。 更多优质资源&#xff0c;请访问我的GitHub仓库&#xff1a;https://github.com/Despacito0o/FreeRTO…

论坛系统(测试报告)

文章目录 一、项目介绍二、设计测试用例三、自动化测试用例的部分展示用户名或密码错误登录成功编辑自己的帖子成功修改个人信息成功回复帖子信息成功 四、性能测试总结 一、项目介绍 本平台是用Java开发&#xff0c;基于SpringBoot、SpringMVC、MyBatis框架搭建的小型论坛系统…

【汽车产品开发项目管理——端到端的汽车产品诞生流程】

MPU&#xff1a;集成运算器、寄存器和控制器的中央处理器芯片 MCU&#xff1a;微控制单元&#xff0c;将中央处理器CPU、存储器ROM/RAM、计数器、IO接口及多种外设模块集成在单一芯片上的微型计算机系统。 汽车产品开发项目属性&#xff1a;临时性、独特性、渐进明细性、以目标…

从零到有的游戏开发(visual studio 2022 + easyx.h)

引言 本文章适用于C语言初学者掌握基本的游戏开发&#xff0c; 我将用详细的步骤引领大家如何开发属于自己的游戏。 作者温馨提示&#xff1a;不要认为开发游戏很难&#xff0c;一些基本的游戏逻辑其实很简单&#xff0c; 关于游戏的开发环境也不用担心&#xff0c;我会详细…

【C++初阶】--- vector容器功能模拟实现

1.什么是vector&#xff1f; 在 C 里&#xff0c;std::vector 是标准模板库&#xff08;STL&#xff09;提供的一个非常实用的容器类&#xff0c;它可以看作是动态数组 2.成员变量 iterator _start;&#xff1a;指向 vector 中第一个元素的指针。 iterator _finish;&#x…

Elasticsearch 学习规划

Elasticsearch 学习规划 明确学习目标与动机 场景化需求分析 - **S**&#xff1a;掌握Elasticsearch架构体系&#xff0c;熟练使用Elasticsearch 进行数据分析,Elasticsearch结合java 项目落地案例 - **M**&#xff1a;搜索和Elasticsearch相关GitHub项目 - **A**&#xff1a;每…

LVM 扩容详解

目录 一、LVM扩容 1. 查看磁盘分区情况&#xff1a; 2. 查看pv、vg、lv 情况 3. 将新硬盘分区初始化 4. 将初始化后的分区添加到VG中 5. 查看逻辑卷的设备路径 6. VG分配给lv 二、扩展文件系统 1.确认文件系统类型 三、检验 一、LVM扩容 1. 查看磁盘分区情况&#xff1a; …

STM32 低功耗模式下 RTC唤醒 和 PA0唤醒 的配合使用

STM32 低功耗模式不同唤醒源的配合使用 by 矜辰所致前言 关于 STM32 如何实现低功耗模式&#xff0c;我之前写过一篇文章&#xff1a; STM32 使用 STM32CubeMX HAL库实现低功耗模式 各种休眠模式如何实现文中已经讲得很清楚了&#xff0c;但是作为教学文章&#xff0c;文…

QML 弹窗控件:Popup的基本用法与样式

目录 引言相关阅读Popup基本属性工程结构示例实现Main.qml - 主界面SimplePopup.qml - 简单弹窗ModalPopup.qml - 模态弹窗CustomPopup.qml - 自定义样式弹窗AnimatedPopup.qml - 带动画的弹窗 总结工程下载 引言 在现代图形用户界面(GUI)开发中&#xff0c;弹窗(Popup)是一种…

NSS#Round30 Web

小桃的PHP挑战 <?php include jeer.php; highlight_file(__FILE__); error_reporting(0); $A 0; $B 0; $C 0;//第一关 if (isset($_GET[one])){$str $_GET[str] ?? 0;$add substr($str, 0, 1); $add;if (strlen($add) > 1 ) {$A 1;} else {echo $one; } } else…

Multisim 仿真 DC Sweep 双源嵌套扫描嵌套

Multisim仿真工具箱里头有DC Sweep分析方法&#xff0c;分析中可以对两个源参数扫描分析 类似于编程的循环嵌套&#xff1a; for( Source 2 : start value; Increment; Source 2 : stop value;) {for( Source 1 : start value; Increment; Source 2 : stop value;){... //…

Python | 绘制黑底的水平空间分布图

写在前面 记录一下之前为了做PPT汇报画的一张图&#xff0c;虽然最后也没怎么用上。为了方面以后再需要&#xff0c;这里把代码和数据整理放到GitHub上。有兴趣的也可以玩玩 需要的数据 风场数据可以从ERA5的官网下载 https://cds.climate.copernicus.eu/datasets/reanalys…