直播弹幕系统(五)- 整合Stomp替换原生WebSocket方案探究

news2025/1/13 15:49:27

直播弹幕系统(五)- 整合Stomp替换原生WebSocket方案探究

  • 前言
  • 一. STOMP 协议简单介绍
    • 1.1 客户端编码基础
    • 1.2 服务端编码基础
      • 1.2.1 SimpMessagingTemplate
      • 1.2.2 @SendTo 和 @MessageMapping
  • 二. SpringBoot整合STOMP并实现聊天室
    • 2.1 基础配置和依赖
    • 2.2 WebSocket监听器
    • 2.3 其他代码
    • 2.4 前端代码
      • 2.4.1 EJS模板修改
      • 2.4.2 前端页面修改
    • 2.5 最终效果

前言

本篇文章是基于 SpringBoot - WebSocket的使用和聊天室练习 来讲解的。

在设计弹幕系统(目前还是从设计聊天室开始入手,弹幕的React实现后面会开始写)这块,我还是用最原生的WebSocket来进行的。对于服务端而言。无非就是添加@ServerEndpoint注解修饰,通过@OnOpen进行监听等操作。

但是最最最重要的一点是,这种设计系统,WebSocket信息是存储于本地缓存的。而且,在分布式架构下,还需要考虑到消息的一致性。

因此本篇文章,先简单了解下Stomp以及它的聊天室替代方案实现。

一. STOMP 协议简单介绍

STOMP(Simple (or Streaming) Text Orientated Messaging Protocol),即简单文本定向消息协议。

  • 主要用途:它主要用于STOMP客户端和任意的STOMP消息代理之间进行信息交互。
  • 特点:可以建立在WebSocket之上,也可以建立在其他应用协议之上。

STOMP客户端库:ActiveMQRabbitMQ(后期要接入)
STOMP服务端库:stomp.js,附上下载链接 密码: l3qv

STOMP服务端方面,相当于消息队列的Producer。而客户端方面,主要有这么几个操作:

操作内容
CONNECT启动与服务器的流或 TCP 连接
SEND发送消息
SUBSCRIBE订阅主题
UNSUBSCRIBE取消订阅
BEGIN启动事物
COMMIT提交事物
ABORT回滚事物
ACK消息的确认
NACK告诉服务器客户端没有消费该消息
DISCONNECT断开连接

1.1 客户端编码基础

首先,客户端方面,往往需要引入两个js作为支撑:(下载链接上文也给了)

  • stomp.min.jsSTOMP客户端实现库。
  • sockjs.min.js:sockjs,是对原生Websocket的一种封装。

1.初始化STOMP客户端:

const socket = new SockJS('http://localhost:8080/ws');
const stompClient = Stomp.over(socket);

SocketJs构造里面传入WebSocket服务器地址。没错,它使用的是http协议开头,而不是ws协议开头。

2.初始化链接操作,一般有三个参数:

  1. 发送的消息头信息。
  2. 链接成功时的回调函数onConnected
  3. 链接失败时的回调函数onError
stompClient.connect({}, onConnected, onError);

3.订阅主题的方式,一般两个参数:

  1. 订阅的主题地址。
  2. 接收消息的回调函数onMessageReceived
stompClient.subscribe('/topic/public', onMessageReceived);

4.发送消息的方式,一般有三个参数:

  1. 发送的地址。
  2. 发送的消息头信息。
  3. 发送的消息体信息。
stompClient.send('/chat/addUser',
  {},
  JSON.stringify({ sender: getValueByParam('userId'), type: 'JOIN' }),
);

1.2 服务端编码基础

这里我们以Spring整合STOMP的基础上来说。配置类就不说了,下文会贴代码。主要讲一下几个注解的用法。

以上文中,订阅了主题/topic/public,并发送一条消息到/chat/addUser为例。在Java代码中,我们可以像编写正常的RestFul接口一样,写个Controller

@RestController
public class MyController {
	@Autowired
    private SimpMessagingTemplate messagingTemplate;
    
	@MessageMapping("/chat/addUser")
    @SendTo({"/topic/public"})
    public String sendMessage(@Payload Entity entity) {
        return "Hello";
    }

	@PostMapping("/chat/single")
    public void sendSingleMessage(@RequestBody Entity entity) {
        messagingTemplate.convertAndSendToUser("消息接受者userName或者ID", "/single",chatMessage);
    }
}

关注几个重点信息:

1.2.1 SimpMessagingTemplate

SimpMessagingTemplate 用于将消息发送给特定的用户。从上述Demo中我们可以看到有三个参数,发送给特定用户的路由地址就是由前两个参数来决定的。默认情况下,客户端接收一对一消息主题的路径是:

  • /user/ + "消息接受者userName或者ID" + "/single"(第二个参数)。
  • 第三个参数则是消息体。

默认前缀/user/ 可以修改,在配置类中修改

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
    	// 前缀修改,默认是/user/
        config.setUserDestinationPrefix("/user/");
    }
}

备注:为何默认是/user/,因为代码里面直接定死了默认值。
在这里插入图片描述

1.2.2 @SendTo 和 @MessageMapping

我们来看下这俩注解的组合使用:

@MessageMapping("/chat/addUser")
@SendTo({"/topic/public"})

意思就是:

  1. 能够接收到路径为/chat/addUser的消息。
  2. 并将这个方法的返回值,返回给订阅了主题为/topic/public的所有订阅者。也就是一个广播的功能。

当然,也有一对一的通知,也就是@SendToUser注解。使用方法相同。

二. SpringBoot整合STOMP并实现聊天室

先来看下整体的项目架构:
在这里插入图片描述

2.1 基础配置和依赖

1.pom依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.2.RELEASE</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.2</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <version>2.6.7</version>
        <exclusions>
            <exclusion>
                <artifactId>log4j-api</artifactId>
                <groupId>org.apache.logging.log4j</groupId>
            </exclusion>
        </exclusions>
    </dependency>
</dependencies>

2.我们再来思考下,聊天室,一般它的信息类型有三种:

  • 某个用户进入:JOIN
  • 用户的常规聊天:CHAT
  • 某个用户退出:LEAVE

因此我们定义一个枚举类MessageType

public enum MessageType {
    /** 用户聊天 */
    CHAT,
    /** 用户进入直播间 */
    JOIN,
    /** 用户离开直播间 */
    LEAVE
}

3.定义一个常量类LiveConstants,这里只定义了一个RedisKey

public class LiveConstants {
    public static final String LIVE_SET_HASH_KEY = "LiveSetHashKey_";
}

4.工具类JsonUtil

import com.alibaba.fastjson.JSONObject;

/**
 * @author Zong0915
 * @date 2022/12/23 下午12:09
 */
public class JsonUtil {
    public static String toJSON(Object entity) {
        if (entity == null) {
            return "";
        }
        String res;
        try {
            res = JSONObject.toJSONString(entity);
        } catch (Exception e) {
            res = "";
        }
        return res;
    }
}

5.客户端向服务器传输的实体类ChatMessage

import lombok.Data;

/**
 * 消息模型类
 */
@Data
public class ChatMessage {
    /** 消息类型 */
    private MessageType type;
    /** 消息正文 */
    private String content;
    /** 消息发送者 */
    private String sender;
    /** 直播间号 */
    private String roomId;
}

6.服务器向客户端传输的实体类LiveMessage

import lombok.Data;

/**
 * @author Zong0915
 * @date 2022/12/23 上午11:58
 */
@Data
public class LiveMessage {
    private String content;
    private Long count;
    private String type;
}

7.整合STOMP的相关配置类WebSocketConfig

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

/**
 * @author Zong0915
 * @date 2022/12/22 下午2:54
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    /**
     * 注册stomp的端点
     * 注册一个STOMP协议的节点,并映射到指定的URL
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("*")  // 跨域处理
                .withSockJS();  // 支持socketJs
    }

    /**
     * 配置用户路由的前缀,默认是/user/
     * @param config
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setUserDestinationPrefix("/user/");
    }
}

8.application.yml文件:

server:
  port: 8080

spring:
  redis:
    database: 0 # Redis数据库索引(默认为0)
    host: 你的服务器地址 # Redis的服务地址
    port: 6379 # Redis的服务端口
    password: 你的密码
    jedis:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制)
        max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-idle: 8 # 连接池中的最大空闲连接
        min-idle: 0 # 连接池中的最小空闲链接
    timeout: 30000 # 连接池的超时时间(毫秒)

2.2 WebSocket监听器

主要监听两个类型的事件:

  • SessionConnectEvent:连接初始化事件。
  • SessionDisconnectEvent:连接断开事件。
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import zong.constants.LiveConstants;
import zong.constants.MessageType;
import zong.entity.LiveMessage;
import zong.util.JsonUtil;

import java.util.concurrent.TimeUnit;

/**
 * @author Zong0915
 * @date 2022/12/22 下午3:02
 */
@Component
@Slf4j
public class WebSocketEventListener {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    /**
     * 连接建立事件
     *
     * @param event
     */
    @EventListener
    public void handleWebSocketConnectListener(SessionConnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String userId = headerAccessor.getFirstNativeHeader("userId");
        String roomId = headerAccessor.getFirstNativeHeader("roomId");
        String sessionId = headerAccessor.getSessionId();
        if (StringUtils.isBlank(userId) || StringUtils.isBlank(roomId) || StringUtils.isBlank(sessionId)) {
            return;
        }
        log.info("建立一个新的连接,用户ID:{}", userId);
        // 当前直播间的人数(先不计入当前的用户)
        String hashKey = LiveConstants.LIVE_SET_HASH_KEY + roomId;
        // 如果不存在这个HashKey,添加元素并设置过期时间
        if (!redisTemplate.hasKey(hashKey)) {
            // 维护userId和roomId的关系
            redisTemplate.opsForSet().add(hashKey, userId);
            // 这么做是为了让当前直播间维护的活跃人数缓存,只维护一天,避免每次新用户加入,都刷新过期时间
            redisTemplate.expire(hashKey, 1, TimeUnit.DAYS);
        } else {
            redisTemplate.opsForSet().add(hashKey, userId);
        }
        // 建立sessionId和roomId之间的关系
        redisTemplate.opsForValue().set(sessionId, roomId + "-" + userId);
        redisTemplate.expire(sessionId, 1, TimeUnit.DAYS);
        // 这里如果发送群发主题,当前这个Socket链接是接收不到的,因为还没建立完毕。
        // 因此需要前端在建立Socket的时候,手动发起一个问候信息(此时已经建立完链接)。让后端感应然后再次群发。
        // messagingTemplate.convertAndSend("/live/topic_" + roomId, JsonUtil.toJSON(liveMessage));
    }


    /**
     * 连接断开事件
     *
     * @param event
     */
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headerAccessor.getSessionId();
        if (StringUtils.isBlank(sessionId)) {
            return;
        }
        String roomIdAndUserId = redisTemplate.opsForValue().get(sessionId);
        if (StringUtils.isBlank(roomIdAndUserId)) {
            return;
        }
        String[] ids = roomIdAndUserId.split("-");
        String roomId = ids[0];
        String userId = ids[1];
        // 去除Redis中对应roomId下的用户(Set)
        String hashKey = LiveConstants.LIVE_SET_HASH_KEY + roomId;
        redisTemplate.opsForSet().remove(hashKey, userId);
        Long size = redisTemplate.opsForSet().size(hashKey);
        // 删除sessionId
        redisTemplate.delete(sessionId);
        LiveMessage liveMessage = new LiveMessage();
        liveMessage.setContent("用户[" + userId + "]离开直播间");
        liveMessage.setCount(size);
        liveMessage.setType(MessageType.LEAVE.toString());
        // 向其他用户进行广播,当前用户都退出了,肯定是无需广播的,因此这里可以直接这么写
        messagingTemplate.convertAndSend("/live/topic_" + roomId, JsonUtil.toJSON(liveMessage));
    }
}

主要在连接初始化的时候做这么几个事情:

  1. 维护当前直播间有哪些用户(Redis
  2. 维护当前会话(SessionId)和用户直播信息直接的关联(Redis

那么在链接断开的时候,同理需要去维护这么几个信息:

  1. 需要删除Redis中的会话信息,以及将当前直播间中的当前用户剔除。
  2. 通知其他客户端,在线人数发生变更。

2.3 其他代码

业务层代码ChatService:

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import zong.constants.LiveConstants;
import zong.constants.MessageType;
import zong.entity.ChatMessage;
import zong.entity.LiveMessage;
import zong.util.JsonUtil;

/**
 * @author Zong0915
 * @date 2022/12/22 下午5:15
 */
@Service
public class ChatService {
    @Autowired
    private SimpMessagingTemplate messagingTemplate;
    @Autowired
    private StringRedisTemplate redisTemplate;

    public void messageProcess(ChatMessage chatMessage) {
        if (chatMessage == null) {
            return;
        }
        // 当前直播间的人数(先不计入当前的用户)
        String hashKey = LiveConstants.LIVE_SET_HASH_KEY + chatMessage.getRoomId();
        if (chatMessage.getType() == MessageType.JOIN) {
            // 更新在线人数和提示
            Long size = redisTemplate.opsForSet().size(hashKey);
            LiveMessage liveMessage = new LiveMessage();
            liveMessage.setContent("欢迎用户[" + chatMessage.getSender() + "]加入直播间");
            liveMessage.setCount(size);
            liveMessage.setType(MessageType.JOIN.toString());
            messagingTemplate.convertAndSend("/live/topic_" + chatMessage.getRoomId(), JsonUtil.toJSON(liveMessage));
            return;
        }
        // 如果是普通的聊天,即CHAT类型、稍微封装下消息广播即可。LEAVE用户离开的类型在监听器里面完成了
        LiveMessage liveMessage = new LiveMessage();
        liveMessage.setContent("用户 [" + chatMessage.getSender() + "] 说:" + chatMessage.getContent());
        liveMessage.setType(MessageType.CHAT.toString());
        // 当前直播间人数
        messagingTemplate.convertAndSend("/live/topic_" + chatMessage.getRoomId(), JsonUtil.toJSON(liveMessage));
    }
}

Controller层代码ChatController

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.web.bind.annotation.RestController;
import zong.entity.ChatMessage;
import zong.service.ChatService;

/**
 * @author Zong0915
 * @date 2022/12/22 下午3:01
 */
@RestController
public class ChatController {
    @Autowired
    private ChatService chatService;

    /**
     * 客户端发送消息入口,群发消息
     */
    @MessageMapping("/live/sendMessage")
    public void sendMessage(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
        chatService.messageProcess(chatMessage);
    }
}

2.4 前端代码

前端代码可以看我的这篇文章 UmiJs整合Egg,里面附带完整的代码链接。

主要有这么几个更改点:

2.4.1 EJS模板修改

EJS模板修改,引入socketstompjs文件。这里可以使用我上文给出的链接,也可以使用CDN(我这里用的就是)。

修改的部分内容截图如下:
在这里插入图片描述
值得注意的是:

  1. 引入的外部文件要最好优先于umi.js文件的加载。因为默认是从上往下进行顺序加载的。
  2. 我们将前端页面需要用到的几个对象SockJSStomp挂载到window上,这样前端就可以引用了。(或许也有其他的方法)

文件所在位置:
在这里插入图片描述

完整代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Test</title>

    <% if (envName == "dev") { %>
        <%- helper.assets.getStyle('umi.css') %>
            <% } else { %>
                <link rel="stylesheet" type="text/css" href='/<%- contextPath %>/public/umi.css?v=<%- fileVersion %>' />
                <% } %>
</head>

<body>
    <div id='root' class='subRootContent'>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <% if (envName == 'dev') { %>
        <%- (helper.assets.getScript('umi.js')) %>
            <% } else { %>
                <script src='/<%- contextPath %>/public/umi.js?v=<%- fileVersion %>'></script>
                <% } %>
    <script>
        window.resourceBaseUrl = '<%= helper.assets.resourceBase %>';
        <% if (envName != "dev") { %>
        window.staticUrl = '/<%- contextPath %>/public'
        window.resourceBaseUrl = '/<%- contextPath %><%= helper.assets.resourceBase %>';
        <% } %>
        window.publicPath = resourceBaseUrl;
        window.SockJS=SockJS;
        window.Stomp=Stomp;
    </script>
</body>

</html>

2.4.2 前端页面修改

先给个工具函数,用于获取URL上的参数

export function getValueByParam(param: string): any {
  const url = window.location.href;
  const queryParams = url.split('?');
  if (queryParams?.length < 2) {
    return '';
  }
  const queryList = queryParams[1].split('&');
  for (const key of queryList) {
    if (key.split('=')[0] === param) {
      return key.split('=')[1];
    }
  }
  return '';
}

主要修改index.tsx文件,完整内容如下:

import React, { useEffect, useState } from 'react';
import { Button, Row, Col, Input } from 'antd';
import { getValueByParam } from '../utils/pageHelper';
const SockJS = window.SockJS;
const Stomp = window.Stomp;
const socket = new SockJS('http://localhost:8080/ws');
const stompClient = Stomp.over(socket);
const roomId = getValueByParam('roomId');
const userId = getValueByParam('userId');

const UserPage = () => {
  const [ message, setMessage ] = useState<string>('');
  const [ bulletList, setBulletList ] = useState<any>([]);
  const [ onlineCount, setOnlineCount ] = useState<number>(0);

  useEffect(() => {
    const onMessageReceived = (msg:any) => {
      const entity = JSON.parse(msg.body);
      const arr :any = [ entity.content ];
      setBulletList((pre: any[]) => [].concat(...pre, ...arr));
      if (entity.type === 'JOIN' || entity.type === 'LEAVE') {
        setOnlineCount(entity.count ?? 0);
      }
    };

    const onConnected = () => {
      // 订阅群发主题
      stompClient.subscribe(`/live/topic_${roomId}`, onMessageReceived);

      const chatMessage = {
        sender: userId,
        type: 'JOIN',
        roomId,
      };

      stompClient.send('/live/sendMessage',
        {},
        JSON.stringify(chatMessage),
      );
    };

    const onError = (error:any) => {
      console.log(error);
    };
	// 请求头
	const header = { userId, roomId };
    stompClient.connect(header, onConnected, onError);
  }, []);

  const sendMsg = () => {
    const chatMessage = {
      sender: userId,
      content: message,
      type: 'CHAT',
      roomId,
    };

    stompClient.send('/live/sendMessage',
      {},
      JSON.stringify(chatMessage),
    );
  };

  return <>
    <Row style={{ width: 2000, marginTop: 200 }}>
      <Col offset={6}>
        <Input onChange={event => setMessage(event.target.value)} />
      </Col>
      <Col>
        <Button
          onClick={sendMsg}
          type='primary'
        >发送弹幕</Button>
      </Col>
      <Col style={{ marginLeft: 100 }}>
        {'在线人数: ' + onlineCount}
      </Col>
      <Col style={{ marginLeft: 10 }}>
        <div style={{ border: '1px solid', width: 500, height: 500 }}>
          {bulletList.map((item: string, index: number) => {
            return <Row key={index}>
              {item}
            </Row>;
          })}
        </div>
      </Col>
    </Row>
  </>;
};

export default UserPage;

2.5 最终效果

这里偷个懒,动图演示就不做了。首先访问页面1:http://localhost:4396/zong/?userId=LJJ&roomId=1
在这里插入图片描述
打开另外一个窗口:http://localhost:4396/zong/?userId=Zong&roomId=1,页面1出现提示,并且实时更新了在线人数。
在这里插入图片描述

倘若页面1当中发送文字:
在这里插入图片描述
页面2中提示:
在这里插入图片描述
关闭页面2,页面1提示:
在这里插入图片描述

到这里SpringBoot整合STOMP,并且替代原有的WebSocket完成在线聊天室的功能就完成了。

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

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

相关文章

华为EC6108V9C免拆卡刷固件包

华为EC6108V9C免拆卡刷固件包 固件特点&#xff1a; 1、修改dns&#xff0c;三网通用&#xff1b; 2、开放原厂固件屏蔽的市场安装和u盘安装apk&#xff1b; 3、无开机广告&#xff0c;无系统更新&#xff0c;不在被强制升级&#xff1b; 4、大量精简内置的没用的软件&…

Redis高级篇之最佳实践

Redis高级篇之最佳实践 本章内容 Redis 键值设计批处理优化服务端优化集群最佳实践 笔记整理自 b站_黑马程序员Redis入门到实战教程 1. Redis键值设计 优雅的key结构 Redis 的 Key 虽然可以自定义&#xff0c;但最好遵循下面的几个最佳实践约定&#xff1a; 遵循基本格式&a…

Docker容器中安装Jenkins

众所周知,jenkins是现在比较流行的一种工具,今天就记录一下在工作中如何使用了jenkins&#xff0c; 由于我使用的使用Linux(Debain 11)开发环境使用了jdk1.8&#xff0c;会跟最新版的jenkins(官方介绍最新版要jdk11支持)有不良的化学反应&#xff0c;所以把jenkins放到了容器中…

Hive+Spark离线数仓工业项目实战--项目介绍及环境构建(2)

Docker的介绍 了解Docker的基本功能和设计 - 为什么要用Docker&#xff1f; - 什么是Docker&#xff1f; 路径 - step1&#xff1a;生产环境的问题 - step2&#xff1a;容器的概念 - step3&#xff1a;Docker的设计 实施 生产环境的问题 - 运维层面&#xff1a;一…

Windows下的通用进程守护程序(持续更新中),高仿supervisor。

&#x1f4e2;欢迎点赞 &#xff1a;&#x1f44d; 收藏 ⭐留言 &#x1f4dd; 如有错误敬请指正&#xff0c;赐人玫瑰&#xff0c;手留余香&#xff01;&#x1f4e2;本文作者&#xff1a;由webmote 原创&#x1f4e2;作者格言&#xff1a;无尽的折腾后&#xff0c;终于又回到…

【Three.js入门】标准网格材质、置换贴图、粗糙度贴图、金属贴图、法线贴图

个人简介 &#x1f440;个人主页&#xff1a; 前端杂货铺 &#x1f64b;‍♂️学习方向&#xff1a; 主攻前端方向&#xff0c;也会涉及到服务端 &#x1f4c3;个人状态&#xff1a; 在校大学生一枚&#xff0c;已拿多个前端 offer&#xff08;秋招&#xff09; &#x1f680;未…

【实时数仓】DWS层的定位、DWS层之访客主题计算(PV、UV、跳出次数、计入页面数、连续访问时长)

文章目录一 DWS层与DWM层的设计1 设计思路2 需求梳理3 DWS层定位二 DWS层-访客主题计算1 需求分析与思路2 功能实现&#xff08;1&#xff09;封装VisitorStatsApp&#xff0c;读取Kafka各个流数据a 代码b 测试&#xff08;2&#xff09;合并数据流a 封装主题宽表实体类Visitor…

CMakeList

目录 .1 简介 .2 常用命令 2.1 指定 cmake 的最小版本 2.2 设置项目名称 2.3 设置编译类型 2.4 指定编译包含的源文件 2.4.1 明确指定包含哪些源文件 2.4.2 搜索所有的 cpp 文件 2.4.3自定义搜索规则 2.5 查找指定的库文件 2.6. 设置包含的目录 2.7. 设置链接库搜索…

中国晶振市场规模将增长至2026年的263.21亿元,国产市场未来可期

晶振作为频率控制和频率选择基础元件&#xff0c;广泛应用于资讯设备、移动终端、通信及网络设备、汽车电子、智能电表、电子银行口令卡等领域&#xff0c;随着新兴电子产业、物联网的快速发展&#xff0c;及以 5G、蓝牙 5.0、Wi-Fi 6 等无线通信新技术的广泛应用&#xff0c;预…

基于Python的Flask WEB框架实现后台权限管理系统(含数据库),内容包含:用户管理、角色管理、资源管理和机构管理

#基于Flask实现后台权限管理系统 重磅&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;&#xff01; 全新的风格界面&#xff0c;完全的前后端分离。基于ElementUI&#xff0c;前端代码基于RuoYi…

plotly parallel_coordinates平行坐标可视化

使用plotly画平行坐标图&#xff0c;代码如下&#xff1a; 其中数据使用excel的csv格式&#xff08;当然可以使用其它格式&#xff09;&#xff0c;csv的标头是参数名。 import plotly.express as px import numpy as np import pandas as pd# df px.data.iris() df pd.read…

【爬虫】JS逆向解决反爬问题系列3—sign破解

⭐️⭐️⭐️⭐️⭐️欢迎来到我的博客⭐️⭐️⭐️⭐️⭐️ 🐴作者:秋无之地 🐴简介:CSDN爬虫、后端、大数据领域创作者。目前从事python爬虫、后端和大数据等相关工作,主要擅长领域有:爬虫、后端、大数据开发、数据分析等。 🐴欢迎小伙伴们点赞👍🏻、收藏⭐️、…

DOM(一):获取页面元素、操作元素

DOM&#xff08;一&#xff09;获取页面元素事件操作元素获取页面元素 1.根据ID获取 使用getElementById()方法获取带有ID的元素对象,格式如下&#xff1a; var 变量名 document.getElementById(‘id名’); 例如&#xff1a; <div id time>2022-12-18</div> &…

PyTorch——应用一个已训练好的图片分类网络——AlexNet

1.识别一个图像主体的预训练网络 ImageNet数据集是由一个Stanford大学维护的包含1400多万幅图像的非常大的数据集&#xff0c;所有的图像都用来自WordNet数据集的名词层次结构标记&#xff0c;而WordNet数据集又是一个大型的英语词汇数据库。 1.1获取一个预先训练好的网络用于…

【矩阵论】7.范数理论——基本概念——矩阵范数生成向量范数谱范不等式

7.1.3 矩阵范数产生向量范数 CnnC^{n\times n}Cnn 上任一矩阵范数 ∥∙∥\Vert \bullet\Vert∥∙∥ 都产生一个向量范数 φ(X)∥X∥V\varphi(X)\Vert X\Vert_Vφ(X)∥X∥V​ 矩阵范数与向量范数的相容性&#xff1a;φ(Ax)≤∥A∥φ(x)\varphi(Ax)\le \Vert A\Vert\varphi(x)φ…

蓝牙耳机什么牌子好?音质好、配置高的蓝牙耳机分享

​经常听到很多网友在讨论哪些蓝牙耳机好用&#xff0c;什么蓝牙耳机的配置高......选购蓝牙耳机无非就是音质、蓝牙技术、续航、佩戴体验等各方面条件&#xff0c;还有哪位朋友不知道该如何选购蓝牙耳机的&#xff1f;根据我对蓝牙耳机的了解&#xff0c;从网上整理了好几款蓝…

【关于eps8266自动重启 Soft WDT reset】

【关于eps8266自动重启 Soft WDT reset】1. 前言2. 分析问题2.1 长时间没有喂狗2.2 delayMicroseconds 函数触发3. 解决问题3.1 解决长时间没有喂狗3.2 解决delayMicroseconds 函数触发5. 小结1. 前言 最近使用esp8266进行远程遥控时, 但是在驱动舵机servo库的过程中出现了esp…

第二证券|两大板块掀涨停潮,有个股猛拉20cm!这只港股复牌一度暴跌

A股商场周五上午窄幅震动&#xff0c;上证指数微涨0.09点&#xff0c;核算机等板块领涨。 新股体现持续分解&#xff0c;4只今日上市的新股中&#xff0c;两只科创板新股上涨&#xff0c;两只北交所新股则破发。 港股全体小幅调整&#xff0c;全体动摇不算大&#xff0c;但仍…

【推荐】智慧农业解决方案资料合集30篇

智慧农业 是农业中的智慧经济&#xff0c;智慧经济形态在农业中的具体表现。智慧农业是智慧经济重要的组成部分&#xff1b;对于发展中国家而言&#xff0c;智慧农业是智慧经济主要的组成部分&#xff0c;是发展中国家消除贫困、实现后发优势、经济发展后来居上、实现赶超战略的…

16含风光水的虚拟电厂与配电公司协调调度模型(场景削减MATLAB程序)

参考文献 含风光水的虚拟电厂与配电公司协调调度模型——董文略&#xff08;复现场景削减部分&#xff09; 主要内容 代码主要做的是风电、光伏以及电价场景不确定性模拟&#xff0c;首先由一组确定性的方案&#xff0c;生成50种光伏场景&#xff0c;为了避免大规模光伏场景…