在springboot中实现WebSocket协议通信
前面介绍了使用netty实现websocket通信,有些时候,如果我们的服务并不复杂或者连接数并不高,单独搭建一个websocket服务端有些浪费资源,这时候我们就可以在web服务内提供简单的websocket连接支持。其实springboot已经支持了websocket通信协议,只需要几步简单的配置就可以实现。
老规矩,首先需要引入相关的依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.12</version><scope>provided</scope>
</dependency>
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.12.0</version>
</dependency>
springboot的配置文件application.yaml不需要额外内容,简单指定一下端口号和服务名称就可以了:
server:port: 8081shutdown: gracefulspring:application:name: test-ws
由于我这里使用了日志,简单配置一下日志文件logback-spring.xml输出内容:
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds" debug="false"><contextName>api-logger-server</contextName><!-- 控制台 --><appender name="console" class="ch.qos.logback.core.ConsoleAppender"><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS}|%thread|[%-5level]|%logger{36}.%method|%msg%n</pattern><charset>UTF-8</charset></encoder></appender><!--业务日志 文件--><appender name="msg" class="ch.qos.logback.core.rolling.RollingFileAppender"><file>${user.dir}/logs/msg.log</file><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS}|%thread|[%-5level]|%logger{36}.%method|%msg%n</pattern><charset>UTF-8</charset></encoder><rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"><FileNamePattern>${user.dir}/logs/msg.log.%d{yyyy-MM-dd}</FileNamePattern></rollingPolicy></appender><logger name="msg" level="ERROR" additivity="false"><appender-ref ref="msg"/></logger><!--收集除error级别以外的日志--><appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender"><filter class="ch.qos.logback.classic.filter.LevelFilter"><level>ERROR</level><onMatch>DENY</onMatch><onMismatch>ACCEPT</onMismatch></filter><encoder><pattern>%d|%t|%-5p|%c|%m%n</pattern><charset>UTF-8</charset></encoder><rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"><!--路径--><fileNamePattern>${user.dir}/logs/info/%d.%i.log</fileNamePattern><maxFileSize>100MB</maxFileSize><!--日志文件保留天数--><maxHistory>15</maxHistory><!--超过该大小,删除旧文件--><totalSizeCap>10GB</totalSizeCap></rollingPolicy></appender><appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender"><filter class="ch.qos.logback.classic.filter.ThresholdFilter"><level>ERROR</level></filter><encoder><pattern>%d|%t|%-5p|%c|%m%n</pattern><charset>UTF-8</charset></encoder><!--滚动策略--><rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"><!--路径--><fileNamePattern>${user.dir}/logs/error/%d.%i.log</fileNamePattern><maxFileSize>100MB</maxFileSize><!--日志文件保留天数--><maxHistory>15</maxHistory><!--超过该大小,删除旧文件--><totalSizeCap>10GB</totalSizeCap></rollingPolicy></appender><root level="INFO"><appender-ref ref="console"/><appender-ref ref="INFO"/><appender-ref ref="ERROR"/></root>
</configuration>
本项目只是简单演示在springboot中使用websocket功能,所以没有涉及到复杂的业务逻辑,但还是需要定义一个用户服务类,用来存储用户身份信息和登录时的身份校验。
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.stereotype.Service;import javax.annotation.PostConstruct;
import java.util.concurrent.ConcurrentHashMap;/*** 用户服务类** @Author xingo* @Date 2023/11/22*/
@Slf4j
@Service
public class UserService {static final ConcurrentHashMap<String, User> USER_MAP = new ConcurrentHashMap<>();static final ConcurrentHashMap<String, String> TOKEN_MAP = new ConcurrentHashMap<>();/*** 启动时存入信息*/@PostConstructpublic void run() {User user1 = User.builder().userName("zhangsan").nickName("张三").build();User user2 = User.builder().userName("lisi").nickName("李四").build();// 用户信息集合USER_MAP.put(user1.getUserName(), user1);USER_MAP.put(user2.getUserName(), user2);// 模拟用户登录成功,将身份认证的token放入集合String random1 = "token_" + RandomStringUtils.random(18, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890");String random2 = "token_" + RandomStringUtils.random(18, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890");log.info("用户身份信息|{}|{}", user1.getUserName(), random1);log.info("用户身份信息|{}|{}", user2.getUserName(), random2);TOKEN_MAP.put(random1, user1.getUserName());TOKEN_MAP.put(random2, user2.getUserName());}/*** 根据用户名获取用户信息*/public User getUserByUserName(String userName) {return USER_MAP.get(userName);}/*** 校验token和用户是否匹配*/public boolean checkToken(String token, String userName) {return userName.equals(TOKEN_MAP.get(token));}/*** 用户信息实体类*/@Data@Builderpublic static final class User {private String userName;private String nickName;}
}
接下来就是websocket相关注入到容器中,首先需要注入的是ServerEndpointExporter,这个类用来扫描ServerEndpoint相关内容:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;/*** 注入ServerEndpointExporter,用来扫描ServerEndpoint相关注解** @author xingo* @Date 2023/11/22*/
@Configuration
public class WebsocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}
}
接下来需要再注入一个Bean,这个Bean需要添加ServerEndpoint注解,主要用来处理websocket连接。注意这个Bean是多例的,每个websocket连接都会新建一个实例。
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;/*** websocket服务类* 连接ws服务这里要两个参数:userName 和 token* userName 用于用户身份标识* token 用于用户身份认证,用户每次登录进入系统都有可能不同** @author xingo* @Date 2023/11/22*/
@Slf4j
@Component
@ServerEndpoint("/{userName}/{token}")
public class WebSocketEndpoint {/*** 存放所有在线的客户端:键为用户名,值为用户的所有连接*/public static final Map<String, List<Session>> USER_SESSIONS = new ConcurrentHashMap<>();/*** 存放连接最近一次写数据的时间戳*/public static final Map<Session, Long> LAST_REQUEST_TIME = new ConcurrentHashMap<>();// ServerEndpoint 是多例的,需要设置为静态的类成员,否则程序运行会出错private static UserService userService;// 只能通过属性的set方法注入@Autowiredpublic void setUserService(UserService userService) {WebSocketEndpoint.userService = userService;}/*** 客户端连接* @param session*/@OnOpenpublic void onOpen(Session session, EndpointConfig config, @PathParam("userName") String userName, @PathParam("token") String token) {System.out.println("客户端连接|" + userName + "|" + token + "|" + session);System.out.println(this);System.out.println(userService);LAST_REQUEST_TIME.put(session, System.currentTimeMillis());if(StringUtils.isNotBlank(userName) && StringUtils.isNotBlank(token)) {boolean flag = false;boolean check = userService.checkToken(token, userName);if(check) {UserService.User user = userService.getUserByUserName(userName);if(user != null) {if(!USER_SESSIONS.containsKey(userName)) {USER_SESSIONS.put(userName, new ArrayList<>());}USER_SESSIONS.get(userName).add(session);flag = true;}}if(flag) {session.getAsyncRemote().sendText("连接服务端成功");} else {session.getAsyncRemote().sendText("用户信息认证失败,连接服务端失败");}} else {session.getAsyncRemote().sendText("未获取到用户身份验证信息");}}/*** 客户端关闭* @param session session*/@OnClosepublic void onClose(Session session, CloseReason closeReason, @PathParam("userName") String userName, @PathParam("token") String token) {System.out.println("客户端断开|" + userName + "|" + token + "|" + session);if(StringUtils.isNotBlank(userName)) {USER_SESSIONS.get(userName).remove(session);LAST_REQUEST_TIME.remove(session);}LAST_REQUEST_TIME.remove(session);}/*** 发生错误* @param throwable e*/@OnErrorpublic void onError(Session session, Throwable throwable) {throwable.printStackTrace();}/*** 收到客户端发来消息* @param message 消息对象*/@OnMessagepublic void onMessage(Session session, String message, @PathParam("userName") String userName, @PathParam("token") String token) {log.info("接收到客户端消息|{}|{}|{}|{}", userName, token, session.getId(), message);LAST_REQUEST_TIME.put(session, System.currentTimeMillis());String resp = null;try {if("PING".equals(message)) {resp = "PONG";} else if("PONG".equals(message)) {log.info("客户端响应心跳|{}", session.getId());} else {resp = "服务端收到信息 : " + message;}} catch (Exception e) {e.printStackTrace();}if(resp != null) {sendMessage(userName, resp);}}/*** 发送消息* @param userName 用户名* @param data 数据体*/public static void sendMessage(String userName, String data) {List<Session> sessions = USER_SESSIONS.get(userName);if(sessions != null && !sessions.isEmpty()) {sessions.forEach(session -> session.getAsyncRemote().sendText(data));} else {log.error("客户端未连接|{}", userName);}}/*** 初始化方法执行标识*/public static final AtomicBoolean INIT_RUN = new AtomicBoolean(false);/*** 处理长时间没有与服务器进行通信的连接*/@PostConstructpublic void run() {if(INIT_RUN.compareAndSet(false, true)) {log.info("检查连接定时任务启动");ScheduledExecutorService service = Executors.newScheduledThreadPool(1);service.scheduleAtFixedRate(() -> {// 超时关闭时间:超过5分钟未更新时间long closeTimeout = System.currentTimeMillis() - TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES);// 心跳包时间:超过2分钟未更新时间long heartbeatTimeout = System.currentTimeMillis() - TimeUnit.MICROSECONDS.convert(2, TimeUnit.MINUTES);Iterator<Map.Entry<Session, Long>> iterator = LAST_REQUEST_TIME.entrySet().iterator();while (iterator.hasNext()) {Map.Entry<Session, Long> next = iterator.next();Session session = next.getKey();long lastTimestamp = next.getValue();if(lastTimestamp < closeTimeout) { // 超时链接关闭log.error("关闭超时连接|{}", session.getId());try {session.close();iterator.remove();USER_SESSIONS.entrySet().forEach(entry -> entry.getValue().remove(session));} catch (IOException e) {e.printStackTrace();}} else if(lastTimestamp < heartbeatTimeout) { // 发送心跳包log.info("发送心跳包|{}", session.getId());session.getAsyncRemote().sendText("PING");}}}, 5, 10, TimeUnit.SECONDS);}}
}
对于上面的Bean需要几点说明:
- 该Bean是多例的,每个websocket连接都会创建一个实例。在上面连接建立的方法里面输出当前实例对象的内容每个连接输出的内容都不同:
客户端连接|zhangsan|token_JTrFGlBW01gHxFZHFG|org.apache.tomcat.websocket.WsSession@7ef1b79f
org.example.websocket.WebSocketEndpoint@33141901
org.example.service.UserService@46db8a12
客户端断开|zhangsan|token_JTrFGlBW01gHxFZHFG|org.apache.tomcat.websocket.WsSession@7ef1b79f
客户端连接|zhangsan|token_JTrFGlBW01gHxFZHFG|org.apache.tomcat.websocket.WsSession@7116a4f3
org.example.websocket.WebSocketEndpoint@341424b5
org.example.service.UserService@46db8a12
客户端断开|zhangsan|token_JTrFGlBW01gHxFZHFG|org.apache.tomcat.websocket.WsSession@7116a4f3
客户端连接|zhangsan|token_JTrFGlBW01gHxFZHFG|org.apache.tomcat.websocket.WsSession@737a3e9b
org.example.websocket.WebSocketEndpoint@3678be90
org.example.service.UserService@46db8a12
- 在该类中注入其他的Bean要设置为静态属性,并且注入要通过set方法,否则注入失败,之前在项目中使用时就出现过这种问题,将属性定义为成员变量并且直接在属性上面添加@Autowired注解,导致该属性一直是null。
比如我的UserService服务就是通过这种方式注入的:
private static UserService userService;@Autowired
public void setUserService(UserService userService) {WebSocketEndpoint.userService = userService;
}
上面几个类定义好后就实现了在springboot中使用websocket,添加启动类就可以进行前后通信:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;/*** 应用启动类* * @Author xingo* @Date 2023/11/22*/
@SpringBootApplication
public class WsApplication {public static void main(String[] args) {SpringApplication.run(WsApplication.class, args);}
}
为了方便测试,再添加一个controller用于接收消息并将消息转发到客户端:
import org.example.websocket.WebSocketEndpoint;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;/*** @Author xingo* @Date 2023/11/22*/
@RestController
public class MessageController {/*** 发送信息*/@GetMapping("/sendmessage")public String sendMessage(String userName, String message) {WebSocketEndpoint.sendMessage(userName, message);return "ok";}
}
测试服务是否正常。我这里选择使用postman进行测试,通过postman建立连接并发送消息。

连接建立成功,并且正常的发送和接收到数据。
下面测试一下通过http发送数据到服务端,服务端根据用户名查找到对应连接将消息转发到客户端。


这种模拟了服务端主动推送数据给客户端场景,实现了双向通信。
以上就是使用springboot搭建websocket的全部内容,发现还是非常简单,最主要的是可以与现有的项目实行完全融合,不需要做太多的改变。
上面这种方式只是单体服务简单的实现,对于稍微有一点规模的应用都会采用集群化部署,用一个nginx做反向代理后端搭配几个应用服务器组成集群模式,对于集群服务就会涉及到服务间通信的问题,需要将消息转发到用户正在连接的服务上面发送给客户端。后面会讲一下如何通过redis作为中心服务实现服务发现和请求转发的功能。
相关文章:
在springboot中实现WebSocket协议通信
前面介绍了使用netty实现websocket通信,有些时候,如果我们的服务并不复杂或者连接数并不高,单独搭建一个websocket服务端有些浪费资源,这时候我们就可以在web服务内提供简单的websocket连接支持。其实springboot已经支持了websock…...
云原生Docker系列 | Docker私有镜像仓库公有镜像仓库使用
云原生Docker系列 | Docker私有镜像仓库&公有镜像仓库使用 1. 使用公有云镜像仓库1.1. 阿里云镜像仓库1.2. 华为云镜像仓库1.3. 腾讯云镜像仓库2. 使用Docker Hub镜像仓库3. 使用Harbor构建私有镜像仓库4. 搭建本地Registry镜像仓库1. 使用公有云镜像仓库 1.1. 阿里云镜像…...
用于 syslog 收集的协议:TCP、UDP、RELP
系统日志是从 Linux/Unix 设备和其他网络设备(如交换机、路由器和防火墙)生成的日志 可以通过将 syslog 聚合到称为 syslog 服务器、syslog 守护程序或 syslogd 的服务器来集中 syslog。在TCP、UDP和RELP协议的帮助下,系统日志从设备传输到系…...
OpenAI创始人山姆·阿尔特曼重返公司;LLM持续学习
🦉 AI新闻 🚀 OpenAI创始人山姆阿尔特曼重返公司并与微软建立合作伙伴关系 摘要:OpenAI创始人山姆阿尔特曼回归OpenAI,担任首席执行官,并与微软建立牢固的合作伙伴关系。这解决了近期的争论,微软对OpenAI…...
Ant Design Pro生产环境部署
Ant Design Pro是通过URL路径前缀/api访问后端服务器,因此在nginx配置以下代理即可。 location / {index.html } location /api {proxy_pass: api.mydomain.com }...
Altium Designer学习笔记10
再次根据图纸进行布局走线: 这个MT2492 建议的布局走线。 那我这边应该是尽量按照该图进行布局: 其中我看到C1的电容的封装使用的是电感的封装,需要进行更换处理: 执行Validate Changes和Execute Changes操作,更新&a…...
ubuntu cutecom串口调试工具使用方法(图形界面)
文章目录 Ubuntu下使用CuteCom进行串口调试使用指南什么是CuteCom?主要特点 安装CuteCom使用APT包管理器从源码编译安装 配置串口CuteCom界面解析(启动cutecom)使用CuteCom进行数据发送和接收配置串口参数数据接收数据发送 高级功能和技巧流控…...
flink 1.17.1的pom.xml模板
flink 1.17.1的pom.xml模板 <?xml version"1.0" encoding"UTF-8"?><project xmlns"http://maven.apache.org/POM/4.0.0" xmlns:xsi"http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation"http://maven.apa…...
MySql的数据类型和隐式转换
文章目录 一、数据类型1、数值类型1.1、整数类型1.2、浮点1.3、定点DECIMAL 2、时间类型2.1、日期和时间类型占用的存储空间2.2、日期和时间类型表示的范围2.3、日期和时间类型的零值表示 3、文本类型 二、隐式转换参考文章 一、数据类型 1、数值类型 1.1、整数类型 整数名称…...
【开源】基于JAVA的在线课程教学系统
项目编号: S 014 ,文末获取源码。 \color{red}{项目编号:S014,文末获取源码。} 项目编号:S014,文末获取源码。 目录 一、摘要1.1 系统介绍1.2 项目录屏 二、研究内容2.1 课程类型管理模块2.2 课程管理模块2…...
【Linux】权限理解【文件权限以及目录权限详解、以及umsk程序掩码知识详解】
权限理解 一、Linux权限的概念二、su [用户名] : 切换用户三、Linux权限管理文件(一)文件访问者的分类(人)(二)文件类型和访问权限(事物属性)(1)第…...
Leetcode—1410.HTML实体解析器【中等】
2023每日刷题(三十八) Leetcode—1410.HTML实体解析器 算法思想 实现代码 typedef struct entityChar {char* entity;char rechar; }entity;entity matches[] {{""", "},{"'", \},{"&"…...
golang指针学习
package mainimport "fmt"func main() {name:"飞雪无情"nameP:&name//取地址fmt.Println("name变量的内存地址为:",&name)fmt.Println("name变量的值为:",name)fmt.Println("name变量的内存地址为:",nameP)fmt.Prin…...
c语言:用迭代法解决递归问题
题目: 解释:题目的意思就是用迭代法的空间和时间复杂的太高了,需要我们减小空间与时间的复杂度,我就想到了迭代法,思路和代码如下: #include <stdio.h> //这里是递归法转迭代法 int main() {int x,i…...
服务器数据恢复—OCFS2下raid5磁盘损坏导致阵列崩溃的数据恢复案例
服务器数据恢复环境: IBM某型号存储,6块sas硬盘组建一组raid5,划分一个lun分配给Linux服务器并格式化为OCFS2文件系统,共享给虚拟化使用,存放的数据包括24台liunx和windows虚拟机、压缩包文件和配置文件。 服务器故障…...
YOLO目标检测——卫星遥感多类别检测数据集下载分享【含对应voc、coco和yolo三种格式标签】
实际项目应用:卫星遥感目标检测数据集说明:卫星遥感多类别检测数据集,真实场景的高质量图片数据,数据场景丰富,含网球场、棒球场、篮球场、田径场、储罐、车辆、桥、飞机、船等类别标签说明:使用lableimg标…...
基于Towers of Binary Fields的succinct arguments
1. 引言 Ulvetanna团队Benjamin E. Diamond和Jim Posen 2023年论文《Succinct Arguments over Towers of Binary Fields》,开源代码见: https://github.com/recmo/binius(Rust Sage)【基于plonky3等库】 在该论文中࿱…...
【LeetCode刷题笔记】DFSBFS(一)
51. N 皇后 解题思路: DFS + 回溯 :由于 NxN 个格子放 N 个皇后, 同一行不能放置 2 个皇后,所以皇后必然放置在不同行 。 因此,可以从第 0 行开始,逐行地尝试,在每一个 i...
Amazon Generative AI 新世界 | 基于 Amazon 扩散模型原理的代码实践之采样篇
以前通过论文介绍 Amazon 生成式 AI 和大语言模型(LLMs)的主要原理之外,在代码实践环节主要还是局限于是引入预训练模型、在预训练模型基础上做微调、使用 API 等等。很多开发人员觉得还不过瘾,希望内容可以更加深入。因此&#x…...
使用C语言统计一个字符串中每个字母出现的次数
每日一言 Wishing is not enough; we must do. 光是许愿望是不够的; 我们必须行动。 题目 输入一个字符串,统计在该字符串中每个字母出现的次数 例如: 输入:i am a student 输出:a:2 d:1 e:1 i:1 m:1 n:1 s:1 t:2 u:1 大体思路…...
ncmdump:三步解锁网易云音乐格式限制的技术伙伴
ncmdump:三步解锁网易云音乐格式限制的技术伙伴 【免费下载链接】ncmdump 项目地址: https://gitcode.com/gh_mirrors/ncmd/ncmdump 当你从网易云音乐下载了一首心仪的歌曲,却发现它被封装在.ncm格式中,只能在特定客户端播放时&#…...
临床数据挖掘黄金窗口期只剩11个月!——R语言应对ICH E6(R3)电子化源数据新规的5大不可逆技术升级路径
更多请点击: https://intelliparadigm.com 第一章:临床数据挖掘黄金窗口期的倒计时本质与R语言战略定位 临床数据正以前所未有的速度和规模积累——电子病历(EMR)、基因组测序、可穿戴设备流式监测、多中心真实世界研究ÿ…...
题解:AcWing 6030 字符串匹配问题
本文分享的必刷题目是从蓝桥云课、洛谷、AcWing等知名刷题平台精心挑选而来,并结合各平台提供的算法标签和难度等级进行了系统分类。题目涵盖了从基础到进阶的多种算法和数据结构,旨在为不同阶段的编程学习者提供一条清晰、平稳的学习提升路径。 欢迎大…...
ReplaceItems.jsx:Adobe Illustrator设计师必备的批量对象替换神器,5分钟学会工作效率翻倍!
ReplaceItems.jsx:Adobe Illustrator设计师必备的批量对象替换神器,5分钟学会工作效率翻倍! 【免费下载链接】illustrator-scripts Adobe Illustrator scripts 项目地址: https://gitcode.com/gh_mirrors/il/illustrator-scripts 还在…...
5分钟搞定React JSON Schema Form测试覆盖率报告:从配置到可视化全流程
5分钟搞定React JSON Schema Form测试覆盖率报告:从配置到可视化全流程 【免费下载链接】react-jsonschema-form A React component for building Web forms from JSON Schema. 项目地址: https://gitcode.com/gh_mirrors/re/react-jsonschema-form React JS…...
从AD9老用户到AD22新手:我踩过的那些坑和效率翻倍的15个快捷键
从AD9到AD22:一位资深工程师的快捷键迁移指南与实战技巧 第一次打开AD22时,那种感觉就像坐进一辆全新跑车却找不到点火按钮——熟悉的界面下藏着完全不同的操作逻辑。作为从AD9时代就开始画板的老兵,我经历了整整三个月的手忙脚乱,…...
OBS实时字幕插件终极指南:如何为直播添加专业级字幕
OBS实时字幕插件终极指南:如何为直播添加专业级字幕 【免费下载链接】OBS-captions-plugin Closed Captioning OBS plugin using Google Speech Recognition 项目地址: https://gitcode.com/gh_mirrors/ob/OBS-captions-plugin 想要为直播添加实时字幕&#…...
从零到一:Jenkins Pipeline实战,手把手教你搭建企业级CICD流水线(含完整脚本)
从零到一:Jenkins Pipeline实战,手把手教你搭建企业级CICD流水线(含完整脚本) 当团队规模扩张到10人以上时,每天手动部署5次以上的频率会让技术负责人开始思考:如何让代码从提交到上线的时间从2小时缩短到1…...
Qt 5.15.2安装后,你的第一个‘Hello World’程序为什么跑不起来?常见环境配置坑点排查
Qt 5.15.2安装后"Hello World"程序运行失败的深度排查指南 当你满怀期待地完成Qt 5.15.2安装,准备编写第一个"Hello World"程序时,却发现项目无法构建或运行——这种挫败感我深有体会。作为从Qt 4.8时代一路走来的开发者,…...
2025年Mac应用清理新选择:Pearcleaner开源工具深度解析
2025年Mac应用清理新选择:Pearcleaner开源工具深度解析 【免费下载链接】Pearcleaner A free, source-available and fair-code licensed mac app cleaner 项目地址: https://gitcode.com/gh_mirrors/pe/Pearcleaner 在macOS系统中,应用卸载往往留…...
