趁着寒假充实一下技能…
顺便更新一下自己的技术栈Springboot3+Vue3+MybatisPlus+Vite+TS+ElementPlus
路由守卫
import { jwtDecode } from 'jwt-decode';
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) { // 检查路由是否需要登录认证
const token = localStorage.getItem('token');
const isAuthenticated = token !== null;
if (isAuthenticated) {
// 如果本地存储中存在 token,则解析 token 并提取过期时间
try {
const decodedToken = jwtDecode(token);
const expirationDate = decodedToken.exp * 1000; // 将过期时间转换为毫秒
if (Date.now() < expirationDate) {
next(); // 如果 token 有效,则继续导航
} else {
next('/login'); // 如果 token 已过期,则重定向到登录页面
}
} catch (error) {
console.error('Token decoding failed:', error);
next('/login'); // 如果 token 无效或解码失败,则重定向到登录页面
}
} else {
next('/login'); // 如果用户未登录,则重定向到登录页面
}
} else {
next(); // 如果路由不需要登录认证,则继续导航
}
});
Mybatis plus
首先禁用所有的名称转换方法,以保持数据库字段名与实体类字段名完全一致:
mybatis-plus.configuration.map-underscore-to-camel-case=false
在典型的 Spring Boot 应用程序中,通常会遵循 MVC(Model-View-Controller)架构模式。在这个模式中,Controller
层负责处理用户请求,Service
层处理业务逻辑,而 Mapper
或 Repository
层负责数据访问。下面是这些层级的一般职责:
- Controller:接收客户端发送的请求,调用 Service 层的方法处理这些请求,然后返回响应。Controller 层是请求的入口点。
- Service:包含业务逻辑,通常会调用 Mapper/Repository 来访问数据库。Service 层位于 Controller 和 Mapper/Repository 层之间,起到了协调它们的作用。
- Mapper/Repository:直接与数据库交互,执行 CRUD 操作(创建、读取、更新、删除)。在 MyBatis 中,Mapper 接口定义了与数据库交互的方法。
这里放一个实例:
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
@Data
@TableName("comweb_users") //指定需要操作的表名
public class Users {
@TableId // 默认是不会自动生成主码且主码不需要注名
private String userName;
// @TableField("passwd") 指定需要操作的字段,如果不写这一行默认是按照下方定义的字段去匹配,写了可以指定一个不同于字段名的列名,不需要指定长度由数据库自己决定
private String passwd;
private String ywProf;
private String asset;
// 省略 getter 和 setter 方法
// userName = models.CharField(max_length=10, primary_key=True)
// passwd = models.CharField(max_length=10, null=True, blank=True)
// ywProf = models.CharField(max_length=16, null=True, blank=True) # 专业组名字
// asset = models.CharField(max_length=10, null=True, blank=True) # 权限
}
在 MyBatis Plus 中,BaseMapper
提供了一些基本的 CRUD 方法,但它不会自动生成特定于字段的查询方法,如 selectByUserNameAndPasswd
。这种方法需要您自行实现,要么通过定义 XML 映射文件,要么通过使用 MyBatis Plus 提供的注解或者使用 QueryWrapper 来构建查询。
如果您希望 MyBatis Plus 自动生成这样的方法,您需要使用 @Select
注解或者使用 MyBatis Plus 的 IService
和 ServiceImpl
来扩展基本的 CRUD 功能。但请注意,即使使用了 IService
和 ServiceImpl
,也不会自动生成特定的方法,如 selectByUserNameAndPasswd
。您仍然需要自定义这样的方法。
1. 使用 QueryWrapper
自定义查询:
如果您不想写额外的注解或 XML 映射,可以在服务层使用 QueryWrapper
来构建自定义查询条件。这样,您就可以利用 BaseMapper
提供的 selectOne
方法来执行查询,如前面的示例所示。
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.yourpackage.entity.Users;
import com.example.yourpackage.mapper.UsersMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UsersService {
@Autowired
private UsersMapper usersMapper;
public Users getUserByUserNameAndPasswd(String userName, String passwd) {
// 创建 QueryWrapper 实例
QueryWrapper<Users> queryWrapper = new QueryWrapper<>();
// 设置查询条件
queryWrapper.eq("userName", userName).eq("passwd", passwd);
// 执行查询
return usersMapper.selectOne(queryWrapper);
}
}
对应mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.yourpackage.entity.Users;
public interface UsersMapper extends BaseMapper<Users> {
// 这里不需要添加任何额外的方法,因为BaseMapper已经提供了selectOne等CRUD方法
}
对应controller
import com.example.yourpackage.entity.Users;
import com.example.yourpackage.service.UsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UsersController {
@Autowired
private UsersService usersService;
@GetMapping("/login")
public Users getUserByUserNameAndPasswd(@RequestParam String userName, @RequestParam String passwd) {
return usersService.getUserByUserNameAndPasswd(userName, passwd);
}
}
2. 使用注解自定义 SQL:
如果您希望在 Mapper 接口中定义自定义方法并使用注解提供 SQL,您可以这样做:
public interface UsersMapper extends BaseMapper<Users> {
@Select("SELECT * FROM comweb_users WHERE userName = #{userName} AND passwd = #{passwd}")
Users selectByUserNameAndPasswd(@Param("userName") String userName, @Param("passwd") String passwd);
}
@Select("<script>" +
"SELECT * FROM comweb_users WHERE " +
"<foreach collection='filterOptions' item='option' separator=' OR '>" +
" <foreach collection='option.filterValues' item='filter' separator=' OR '>" +
" ${option.columnName} = #{filter}" +
" </foreach>" +
"</foreach>" +
"</script>")
List<Users> selectUsersByFilterOptions(@Param("filterOptions") List<Map<String, List<String>>> filterOptions);
对应controller
package com.example.comweb.controller;
import com.example.comweb.mapper.UsersMapper;
import com.example.comweb.model.Users;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UsersController {
@Autowired
private UsersMapper usersMapper;
@GetMapping("/api/users/login")
public String login(@RequestParam String userName, @RequestParam String passwd) {
Users user = usersMapper.selectByUserNameAndPasswd(userName, passwd);
if (user != null) {
return "Login successful!"; // 登录成功
} else {
return "Login failed!"; // 登录失败
}
}
}
3. 使用 XML 映射文件(plus中可以不使用xml):
如果您更喜欢使用 XML 映射文件,您需要创建一个与 Mapper 接口同名的 XML 文件,在其中定义您的自定义 SQL 语句。然后,确保 MyBatis Plus 能够找到并加载这个 XML 映射文件。
在任何情况下,MyBatis Plus 都不会自动为您生成特定字段的查询方法,您需要手动提供查询逻辑。如果您想避免书写 SQL 语句,应该使用 QueryWrapper
或者 LambdaQueryWrapper
来构建查询条件。
如果你选择使用 MyBatis Plus 的 XML 映射文件而不是注解来定义 SQL 语句,你需要在 mapper
目录下为你的 UsersMapper
接口创建一个对应的 XML 文件。这个文件通常与接口同名,并且放在相同的包路径下。
以下是一个基本的 XML 映射文件例子,它定义了一个通过用户名和密码查询用户的操作:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.yourpackage.mapper.UsersMapper">
<!-- 定义resultMap,映射数据库列到Java实体属性 -->
<resultMap id="UserResultMap" type="com.example.yourpackage.entity.Users">
<id column="id" property="id"/>
<result column="user_name" property="userName"/>
<result column="passwd" property="password"/>
<!-- 其他字段映射 -->
</resultMap>
<!-- 通过用户名和密码查询用户的SQL -->
<select id="selectByUserNameAndPasswd" resultMap="UserResultMap">
SELECT * FROM users
WHERE user_name = #{userName} AND passwd = #{password}
</select>
</mapper>
在这个文件中:
<!DOCTYPE>
声明了 MyBatis 的 DTD,这是为了验证 XML 文件的结构。<mapper>
元素的namespace
属性应该与你的 Mapper 接口的完全限定名匹配。<resultMap>
定义了如何将数据库表的列映射到你的 Java 实体类的属性。id
元素通常用于主键字段,而result
用于其他字段。<select>
元素定义了一个 SQL 查询语句。id
属性应该与你的 Mapper 接口中的方法名匹配。resultMap
属性指定了返回结果应该如何映射到 Java 对象。
现在在你的 UsersMapper
接口中,你可以定义一个与 XML 文件中的 select
语句相匹配的方法:
import com.example.yourpackage.entity.Users;
import org.apache.ibatis.annotations.Param;
public interface UsersMapper extends BaseMapper<Users> {
Users selectByUserNameAndPasswd(@Param("userName") String userName, @Param("password") String password);
}
在你的服务层中,你现在可以直接调用这个方法:
@Service
public class UsersService {
@Autowired
private UsersMapper usersMapper;
public Users getUserByUserNameAndPasswd(String userName, String passwd) {
return usersMapper.selectByUserNameAndPasswd(userName, passwd);
}
}
确保你的 MyBatis 配置文件已经配置了 mapper XML 文件的路径,否则 MyBatis Plus 找不到这些文件。例如,在 mybatis-config.xml
中,你需要有如下配置:
<configuration>
<!-- 其他配置... -->
<mappers>
<mapper resource="com/example/yourpackage/mapper/UsersMapper.xml"/>
<!-- 其他mapper文件 -->
</mappers>
</configuration>
这样,MyBatis Plus 在启动时会加载并解析这些 XML 文件,你就可以在你的应用程序中使用这些自定义的查询了。
Websocket
Websocket通信拦截器
使用 JWT 拦截请求时,ws 请求不能和 http 同时处理,一般有三种方法,独立为 ws 写一个拦截器。或者通过 HeaderMapRequestWrapper
对 WebSocket 请求的 HttpServletRequest
进行包装,在包装过程中将 WebSocket 请求头中的 Sec-WebSocket-Protocol
值提取出来并放入一个名为 Authorization
的请求头中。将 WebSocket 请求转变为标准的 HTTP 请求,从而可以通过常规的 HTTP 过滤器来处理认证逻辑。
/**
* 修改header信息,key-value键值对儿加入到header中
*/
public class HeaderMapRequestWrapper extends HttpServletRequestWrapper {
private static final Logger LOGGER = LoggerFactory.getLogger(HeaderMapRequestWrapper.class);
/**
* construct a wrapper for this request
*
* @param request
*/
public HeaderMapRequestWrapper(HttpServletRequest request) {
super(request);
}
private Map<String, String> headerMap = new HashMap<>();
/**
* add a header with given name and value
*
* @param name
* @param value
*/
public void addHeader(String name, String value) {
headerMap.put(name, value);
}
@Override
public String getHeader(String name) {
String headerValue = super.getHeader(name);
if (headerMap.containsKey(name)) {
headerValue = headerMap.get(name);
}
return headerValue;
}
/**
* get the Header names
*/
@Override
public Enumeration<String> getHeaderNames() {
List<String> names = Collections.list(super.getHeaderNames());
for (String name : headerMap.keySet()) {
names.add(name);
}
return Collections.enumeration(names);
}
@Override
public Enumeration<String> getHeaders(String name) {
List<String> values = Collections.list(super.getHeaders(name));
if (headerMap.containsKey(name)) {
values = Arrays.asList(headerMap.get(name));
}
return Collections.enumeration(values);
}
}
还可以使用过滤器来处理,因为ws握手也是一个http请求。
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
/**
* 通过request的url判断是否为websocket请求
* 如果为websocket请求,先处理Authorization
*/
if(request.getRequestURI().contains("/websocket")){
HeaderMapRequestWrapper requestWrapper = new HeaderMapRequestWrapper((HttpServletRequest) request);
//修改header信息 将websocket请求中的Sec-WebSocket-Protocol值取出处理后放入认证参数key
//此处需要根据自己的认证方式进行修改 Authorization 和 Bearer
requestWrapper.addHeader("Authorization","Bearer "+request.getHeader("Sec-WebSocket-Protocol"));
request = (HttpServletRequest) requestWrapper;
}
//处理完后就可以走系统正常的登录认证权限认证逻辑了
//下边就是自己的验证token 登陆逻辑
LoginUser loginUser = getLoginUser(request);
·······
chain.doFilter(request, response);
}
由于普通的拦截器HandlerInterceptor不能处理ws握手拦截,所以这里使用spring集成的HandshakeInterceptor在握手时拦截:
private final TokenService tokenService;
// @Autowired 已经有构造函数无需自动注入
public WebSocketHandshakeInterceptor(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// wsHandler 表示当前 WebSocket 连接的处理器(Handler)对象。可以通过它获取到处理器的类型、配置信息,或者根据不同的处理器实现不同的拦截逻辑。
// 如果有多个 WebSocket 处理器(例如 /chat 和 /notify),可以在 beforeHandshake 中根据 wsHandler 的类型,对不同的路径进行差异化验证
// attributes用于在握手阶段存储自定义数据。这些数据会在后续 WebSocket 会话中通过 WebSocketSession.getAttributes() 获取,常用于传递上下文信息(如用户身份)
String token = request.getHeaders().getFirst("token");
if (token == null || !tokenService.validateToken(token)) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}
System.out.println("WebSocketHandshakeInterceptor true");
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
// No-op
// WebSocket 握手成功完成后被调用(即客户端和服务器已建立 WebSocket 连接)
// 记录握手成功的日志。
// 释放握手前占用的资源。
// 发送通知或更新状态(例如标记用户在线)
}
拦截器与过滤器的区别
注意过滤器是在拦截器之前处理的,他们都是处理http请求。过滤器是 Servlet 规范的一部分,处理所有 HTTP 请求(包括 WebSocket 握手请求),但无法处理 WebSocket 协议数据帧,可以用消息拦截器。但是拦截器偏向于业务层,也是有它的优势,Servlet Filter是Java EE标准的一部分,由Servlet容器管理,不是Spring管理的Bean,因此默认无法直接注入Spring的Bean。HandlerInterceptor
和 HandshakeInterceptor
可以直接注入 Spring Bean
消息拦截器
无法处理 WebSocket 协议数据帧,可以用消息拦截器,(例如记录日志、权限校验),需使用WebSocketHandler 或 STOMP 的消息拦截器(如 ChannelInterceptor)。
STOMP(Simple Text Oriented Messaging Protocol) 是一种基于文本的简单消息协议,专为消息中间件(如 RabbitMQ、ActiveMQ)设计,用于在客户端和服务器之间定义消息的格式和交互规则。在 WebSocket 场景中,STOMP 被广泛用作 WebSocket 的子协议,为实时通信提供结构化的消息传递机制。
STOMP 的拦截与扩展
权限控制:ChannelInterceptor
在消息到达控制器前进行拦截:
@Configuration
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("token");
if (!validateToken(token)) {
throw new AuthenticationException("Token 无效");
}
}
return message;
}
});
}
}
Websocket优化
数据实时查询有多种方式,可以用短轮询或者长轮询。这里我们探讨websocket。在一开始的项目中,我们使用了定时器在websocket连接成功后就在后端每秒主动推送数据,这样的做法性能开销大:即使数据未变化,每秒查询数据库并向所有客户端推送数据,可能导致数据库负载高和网络带宽浪费;实时性有限:数据更新到客户端推送有最多 1 秒的延迟,对实时性要求极高的场景可能不够。
为此,我们使用数据库触发器结合消息队列(RabbitMQ、Kafka)的方法来优化它。
[数据库变更] → [发送消息到队列] → [WebSocket 服务消费消息] → [推送更新给客户端]
消息发送
RabbitTemplate 是 Spring AMQP 提供的消息发送模板,用于将 Java 对象发送到 RabbitMQ 队列或交换机。
消息消费
SimpleRabbitListenerContainerFactory 工厂用于创建监听容器(MessageListenerContainer
),负责从队列拉取消息并调用 @RabbitListener
方法。
并发处理
ConcurrentHashMap是专门用于处理并发任务的一个哈希,处理哈希冲突时,不同于普通的HashMap完全采用链表,超过阈值会演变成一个红黑树。
乐观锁
适合读多写少,高并发,性能高。
场景1:滴滴抢单
读操作的特点
- 高频实时查询:司机端需要持续刷新订单列表,获取附近的实时订单信息(如位置、价格等),尤其在高峰时段,读取压力极大。
- 缓存优化依赖:系统通常采用缓存(如Redis)存储热点订单数据,降低数据库负载,说明读取频率远高于写入。
- 并发场景:同一订单可能被多个司机同时查看,导致短时间内大量重复读取请求。
写操作的特性
- 低频但关键:每个订单仅需一次成功的状态更新(如抢单成功),写入次数远低于读取次数。
- 并发控制挑战:虽然实际写入次数少,但高并发抢单需通过锁、分布式事务等技术保证数据一致性,可能引发短暂写入竞争。
- 附加日志记录:抢单日志、计费信息等辅助写入可能增加总量,但仍远低于读取量。
场景2:秒杀
读操作的特点
- 超高并发查询:用户反复刷新页面获取实时库存(如“剩余100件”),尤其在活动开始前1分钟,读取量达到峰值。
- 缓存层核心作用:库存信息通常预加载到Redis等缓存中,前端展示的库存是缓存值(例如:本地库存预扣),99%的请求在缓存层完成,无需穿透到数据库。
- 静态资源压力:商品图片、页面HTML等静态内容通过CDN分发,进一步降低服务器读取压力。
写操作的特点
- 瞬时写入风暴:活动开始瞬间,大量用户同时提交订单,触发库存扣减和订单创建,如100万用户抢1万件商品,可能产生数十万级QPS的写入请求。
- 原子性要求极高:库存扣减必须保证“超卖”零容忍,需通过Redis原子操作(
DECR
)或数据库乐观锁实现。 - 异步削峰处理:实际订单创建可能通过消息队列(如Kafka)异步处理,前端仅快速返回“抢购中”状态,缓解数据库写入压力。
悲观锁
适合写多读少,悲观锁每次访问共享资源都要上锁,不适合高并发场景。
锁的分类
分类维度 | 类型 | 特点 | 适用场景 |
---|---|---|---|
作用范围 | 单机锁 | 单进程内线程互斥 | 单服务多线程 |
分布式锁 | 跨进程/服务全局互斥 | 微服务、集群部署 | |
加锁策略 | 悲观锁 | 先加锁再操作 | 高并发写 |
乐观锁 | 先操作再验证(版本号/CAS) | 低冲突场景 | |
实现层级 | 数据库锁 | 如 SELECT FOR UPDATE |
事务性操作 |
代码层锁 | 如 synchronized 、ReentrantLock |
单机多线程 | |
锁粒度 | 粗粒度锁 | 锁整个资源(如整张表) | 简单操作 |
细粒度锁 | 锁资源子集(如某行数据) | 高并发复杂操作 |
异步处理
springboot支持异步处理,不影响正常的请求,比如路由守卫的时候可以加一些计算。
@Async
Json处理
序列化(Serialization)核心概念是 将对象的状态转换为可存储或传输的格式。
总结一下Jackson 的 ObjectMapper
类中常用的 数据转换方法,分为 序列化(对象转 JSON) 和 反序列化(JSON 转对象) 两类:
序列化方法(Java → JSON)
方法 | 作用 | 示例 |
---|---|---|
String writeValueAsString(Object value) |
将 Java 对象转为 JSON 字符串 | String json = objectMapper.writeValueAsString(user); |
byte[] writeValueAsBytes(Object value) |
将 Java 对象转为 JSON 字节数组 | byte[] bytes = objectMapper.writeValueAsBytes(user); |
void writeValue(File resultFile, Object value) |
将 Java 对象转为 JSON 并写入文件 | objectMapper.writeValue(new File("data.json"), user); |
void writeValue(OutputStream out, Object value) |
将 Java 对象转为 JSON 并写入输出流(如网络流) | objectMapper.writeValue(response.getOutputStream(), user); |
void writeValue(Writer writer, Object value) |
将 Java 对象转为 JSON 并写入字符流 | objectMapper.writeValue(new FileWriter("data.json"), user); |
void writeValue(DataOutput out, Object value) |
将 Java 对象转为 JSON 并写入二进制流(高级场景) | objectMapper.writeValue(dataOutput, user); |
反序列化方法(JSON → Java)
方法 | 作用 | 示例 |
---|---|---|
<T> T readValue(String json, Class<T> valueType) |
将 JSON 字符串转为指定类型的 Java 对象 | User user = objectMapper.readValue(json, User.class); |
<T> T readValue(byte[] src, Class<T> valueType) |
将 JSON 字节数组转为指定类型的 Java 对象 | User user = objectMapper.readValue(bytes, User.class); |
<T> T readValue(File src, Class<T> valueType) |
从 JSON 文件读取并转为指定类型的 Java 对象 | User user = objectMapper.readValue(new File("data.json"), User.class); |
<T> T readValue(InputStream src, Class<T> valueType) |
从输入流读取 JSON 并转为指定类型的 Java 对象 | User user = objectMapper.readValue(inputStream, User.class); |
<T> T readValue(Reader src, Class<T> valueType) |
从字符流读取 JSON 并转为指定类型的 Java 对象 | User user = objectMapper.readValue(reader, User.class); |
<T> T readValue(String json, TypeReference<T> valueTypeRef) |
将 JSON 字符串转为复杂泛型类型(如 List<User> ) |
List<User> users = objectMapper.readValue(json, new TypeReference<List<User>>() {}); |
<T> T readValue(JsonParser p, Class<T> valueType) |
从 JsonParser 解析并转为 Java 对象(高级流式解析) |
User user = objectMapper.readValue(parser, User.class); |
Java中不同的流
总结一下java中不同的流。
Stream<String>
流的设计初衷是一次性、单向处理数据(类似迭代器),目的是为了高效处理大数据集,避免重复遍历带来的性能问题。用于对集合(如List、Set)进行高效、声明式的数据处理(如过滤、映射、聚合)。支持终端操作,执行后会关闭流。不存储数据,而是对数据源(集合、数组等)进行计算。支持并行处理。
序列化的字节流
将对象转换为字节流,以便存储到文件、数据库或通过网络传输。通过 ObjectOutputStream 和 ObjectInputStream 实现对象的序列化与反序列化。对象需实现 Serializable 接口。
普通 I/O 流(如 InputStream / OutputStream)
- 目的:处理底层数据读写(如文件、网络、设备输入输出)。
- 分类:
- 字节流:处理原始字节(
InputStream
、OutputStream
)。 - 字符流:处理文本数据(
Reader
、Writer
)。
- 字节流:处理原始字节(
Redis
redis本质是一个存在内存中的字典,负责在数据库面前缓冲请求。它支持多种数据结构,如字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等。
- Redis 并不是纯内存数据库,它支持数据持久化,可以将内存中的数据保存到磁盘中(通过 RDB 或 AOF 机制),从而在重启后恢复数据。
- Redis 使用单线程模型处理命令,避免了多线程的竞争问题,同时通过高效的事件驱动机制实现高并发。
- Redis 支持主从复制、哨兵模式(Sentinel)和集群模式(Cluster),可以构建高可用、可扩展的分布式系统。
Docker
由于redis windows只有5的版本,所以我们使用docker来安装redis。
Docker安装
首先按照 流程 在 控制面板 选择 程序和功能 - 启用或关闭Windows功能,启用 适用于Linux的Windows子系统 和 虚拟机平台 两个windows功能。
然后将WSL 2设置为默认值:
wsl --set-default-version 2
提前创建好 E:\Application\Docker\wsl-data 和 E:\Application\Docker\windows-data 两个文件夹,不然后面会报错。(E:\Application\改为自己的路径)
在 官网 首先下载安装包Docker Desktop Installer.exe
,然后找到命令行安装方式(可以自定义安装路径,默认C盘),以管理员身份启动powershell并在安装包的目录下输入以下命令:
Start-Process 'Docker Desktop Installer.exe' -Wait -ArgumentList 'install', '--accept-license', '--installation-dir=E:\Application\Docker', '--wsl-default-data-root=E:\Application\Docker\wsl-data', '--windows-containers-default-data-root=E:\Application\Docker\windows-data'
powershell执行:
docker -v
docker compose version
可以看到docker和docker compose有了。
docker run hello-world
出现 Hello from Docker! 安装成功。
配置Redis
首先自定义配置redis,选择一个目录创建。
我这里在E:Application\MyRedis,然后下载一个安装包,解压后将里面的 redis.conf 放到这个文件夹下。同时在文件夹下创建 data 和 logs 目录,再创建一个 docker-compose.yaml 文件。目录如下:
myredis
|
|—— docker-compose.yaml #compose配置文件
|—— data #数据存储位置(持久化)
|—— logs #存放日志
-—— redis.conf #redis配置文件
在 docker-compose.yaml 文件里写入:
version: '3.8'
services:
redis:
# 镜像名称以及版本号
image: redis:7.0
# 失败后总是重启
restart: always
# 设置网络
ports:
- 6379:6379
# 自定义容器名
container_name: my_redis
# 文件夹以及文件映射
volumes:
# 本地数据目录:docker数据目录
- ./data:/data
# 注意这里需要在本地先新建redis.conf文件,ro:docker容器对该文件只读,默认是rw可读可写
- ./redis.conf:/etc/redis/redis.conf:ro
- ./logs:/logs
# 以配置文件的方式启动 redis.conf
command: redis-server /etc/redis/redis.conf
在导入的 redis.conf 里面关于requirepass
的部分,要记得设置你的redis密码。
requirepass Your-Password # 示例密码
在 E:Application\MyRedis (你的路径)下右键执行powershell:
前台启动
docker compose up
后台启动
docker compose up -d
关闭
docker compose down
检查redis启动情况,查看容器
docker ps
可以自行下载使用 Another Redis Desktop Manager 查看 redis。
测试:
docker exec -it my_redis redis-cli -a Your-Password # 示例密码
Element Plus
npm install -D unplugin-vue-components unplugin-auto-import
vite.config.ts 按需引入
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
// ...
plugins: [
// ...
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
全局配置,既然全面拥抱 ts,我们写ts
<template>
<el-config-provider :size="size" :z-index="zIndex">
<div id="app">
<router-view></router-view>
</div>
</el-config-provider>
</template>
<script setup lang="ts">
// 删除 export default,因为使用 script setup 会自动导出组件
//在 Vue 3 中,当你使用 `<script setup>` 语法时,组件的名称通常是由其文件名决定的。
import { ref } from 'vue';
// 使用 ref 创建响应式引用
const zIndex = ref(3000);
const size = ref('small');
</script>
<style scoped>
/* 全局样式... */
</style>
Icon
https://www.jb51.net/article/283664.htm
BackTop
回到顶部实现不难,但是容易被打断,第二滚动后立即点击会被中断,如
const scrollToTop = () => {
// 先停止当前所有滚动
window.scrollTo(0, window.scrollY);
// 稍等片刻后开始平滑滚动到顶部
setTimeout(() => {
window.addEventListener('wheel', disableScroll, { passive: false });
window.scrollTo({
top: 0,
behavior: 'smooth'
});
const checkIfAtTop = () => {
if (window.scrollY === 0) {
window.removeEventListener('wheel', disableScroll);
window.removeEventListener('scroll', checkIfAtTop);
}
};
window.addEventListener('scroll', checkIfAtTop);
}, 40); // 延迟40毫秒开始滚动到顶部
};
尝试n多种方法,都不太理想,包括监听滚轮
const disableScroll = (event) => {
event.preventDefault();
};
window.addEventListener('scroll', disableScroll, { passive: false });
// window.removeEventListener('scroll', disableScroll);
实际上这个bug不是鼠标滚轮导致的,因为通过监听可以发现,鼠标滚动停止后再点击回到顶部会被打断,而此时已经没有了滚动,所以阻止滚动是无效的,查看各大平台网站,我自己的hexo主题,还有淘宝他们的网站都是没有这个bug的,这里面包含了比较复杂的动画处理,而且Vue3和TS对Jquery支持有限,无法很好使用Jquery的animate。最后只能采用最简单朴素的方法,就是延迟一小段时间再回到顶部。255已经是不会触发这个bug的最小时间。
const scrollToTop = () => {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: "smooth" // 使用平滑滚动
});
}, 255); // 延迟
};
样式参考
<transition name="slide">
<div class="top-scroll" v-if="showScrollTop" @click="scrollToTop">
<a class="btn-floating" href="#!">
<font-awesome-icon :icon="['fas', 'arrow-up']" style="color: #fff; font-size: 28px;" />
</a>
</div>
</transition>
选用图标如下
import { faArrowUp } from '@fortawesome/free-solid-svg-icons';
library.add(faArrowUp)
sass参考
.top-scroll
position: fixed
bottom: -20px
right: -20px
z-index: 100
padding: 10px
width: 100px
height: 100px
.btn-floating
display: flex
justify-content: center
align-items: center
width: 60%
height: 60%
padding: 0
border-radius: 50%
background: #ff2a4e
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.163)
transition: background 0.5s ease
&:hover
background: #ff513a
.slide-enter-active, .slide-leave-active
transition: all 0.3s ease
.slide-enter-from, .slide-leave-to
transform: translateY(100%)
opacity: 0
这里还有一个坑,缓慢转变颜色不能用渐变色用函数也不行。
完整代码
<script setup lang="ts">
import { RouterLink, useRoute } from "vue-router";
import { ref, onMounted, onBeforeUnmount, watchEffect, onUnmounted } from "vue";
import { library } from '@fortawesome/fontawesome-svg-core'
import $ from 'jquery'
import { faSquareFacebook, faTwitter, faSquareInstagram } from '@fortawesome/free-brands-svg-icons'
import { faArrowUp } from '@fortawesome/free-solid-svg-icons';
// 将图标添加到库中
library.add(faTwitter, faSquareFacebook, faSquareInstagram, faArrowUp)
const route = useRoute();
const isTransparent = ref(false);
const showScrollTop = ref(false);
const scrollToTop = () => {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: "smooth" // 使用平滑滚动
});
}, 255); // 延迟 1000 毫秒(1 秒)执行回到顶部的操作
};
// 监听滚动事件
const handleScroll = () => {
showScrollTop.value = window.scrollY > 100;
isTransparent.value = route.path === '/' && window.scrollY < 50;
};
// 注册和注销滚动事件监听器
onMounted(() => {
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll);
});
watchEffect(() => {
// 当路由为'/'时,根据滚动位置设置透明度
isTransparent.value = route.path === '/' && window.scrollY < 50;
console.log("Current route path:", route.path);
});
const isMenuOpen = ref(false);
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
</script>
<template>
<section id="header1" class="d-flex align-items-center justify-content-between top-0 left-0">
<div class="contactEmail">
Contact Email: <a href="mailto:info@jmymelody.com">info@jmymelody.com</a>
</div>
<!-- <img src="../../img/logo.png" class="logo" alt=""> -->
<nav id="navbar" :class="{ 'active': isMenuOpen }" class="d-flex align-items-center justify-content-center m-0">
<a href="https://www.facebook.com" target="_blank"><font-awesome-icon :icon="['fab', 'square-facebook']" /> Facebook</a>
<a href="https://www.twitter.com" target="_blank"><font-awesome-icon :icon="['fab', 'twitter']" /> Twitter</a>
<a href="https://www.instagram.com" target="_blank"><font-awesome-icon :icon="['fab', 'square-instagram']" /> Instagram</a>
</nav>
<div id="mobile" class="d-none align-items-center">
<a><i class="bi bi-bag bag"></i></a>
<span class="d-flex align-items-end" @click="toggleMenu">
<i class="fas fa-outdent bar"></i>
</span>
</div>
</section>
<section id="header" :class="{'bg-transparent': isTransparent}" class="d-flex align-items-center justify-content-between position-sticky top-0 left-0">
<div class="webName">JMYmelody</div>
<!-- <img src="../../img/logo.png" class="logo" alt=""> -->
<nav id="navbar" :class="{ 'active': isMenuOpen }" class="d-flex align-items-center justify-content-center m-0">
<RouterLink active-class="active" to="/">Home</RouterLink>
<RouterLink active-class="active" to="/shop">Shop</RouterLink>
<RouterLink active-class="active" to="/blog">Blog</RouterLink>
<RouterLink active-class="active" to="/about">About</RouterLink>
<RouterLink active-class="active" to="/contact">Contact</RouterLink>
<RouterLink active-class="active" to="/login">login</RouterLink>
<RouterLink active-class="active" to="/cart"><i id="lg-bag" class="bi bi-bag"></i></RouterLink>
<RouterLink active-class="active" to="/" href="#" id="close" @click="toggleMenu"><i class="bi bi-x-lg"></i></RouterLink>
</nav>
<div id="mobile" class="d-none align-items-center">
<a><i class="bi bi-bag bag"></i></a>
<span class="d-flex align-items-end" @click="toggleMenu">
<i class="fas fa-outdent bar"></i>
</span>
</div>
</section>
<transition name="slide">
<div class="top-scroll" v-if="showScrollTop" @click="scrollToTop">
<a class="btn-floating" href="#!">
<font-awesome-icon :icon="['fas', 'arrow-up']" style="color: #fff; font-size: 28px;" />
</a>
</div>
</transition>
</template>
<style src="../../assets/header.sass" scoped lang="sass"></style>
部署
因为有些情况是需要部署两个 springboot 应用在同一个服务器的,建议采用 nginx 挂代理的方式,例如:
# For more information on configuration, see:
# * Official English Documentation: http://nginx.org/en/docs/
# * Official Russian Documentation: http://nginx.org/ru/docs/
# 全局设置
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;
# 动态模块加载
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
# 日志格式和访问日志位置
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# 基础优化配置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 4096;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Load modular configuration files from the /etc/nginx/conf.d directory.
# See http://nginx.org/en/docs/ngx_core_module.html#include
# for more information.
include /etc/nginx/conf.d/*.conf;
# 默认 HTTP 配置,强制跳转到 HTTPS
server {
listen 80;
listen [::]:80;
server_name 不带www和http(s)的域名;
root /usr/share/nginx/html;
# 重定向所有 HTTP 请求到 HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name 不带www和http(s)的域名;
ssl_certificate ssl证书.crt;
ssl_certificate_key ssl证书.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 主应用代理(8080 端口)
location / {
proxy_pass http://127.0.0.1:8080; # 第一个应用
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 新应用代理(9000 端口)
location /workspace {
proxy_pass http://127.0.0.1:9000; # 第二个应用
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 错误页面配置(可选)
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
这里是将 nginx 挂载在 443,然后通过两个代理部署两个应用。注意挂载新的 http 端口需要查看该端口是否开放,一般在你的服务器提供商那里修改安全组,开放新的端口即可。
在外面的 windows 测试端口开放:
Test-NetConnection -ComputerName <IP> -Port 9000
在服务器上测试端口开放:
telnet <IP> 9000
也可以在 java 里放好 ssl 证书的 jks,这样就不需要用 nginx,可以一个证书挂多个应用:
server.ssl.enabled=true
server.ssl.key-store=classpath:source/ssl证书.jks
server.ssl.key-store-password=略
server.ssl.keyStoreType=JKS
需要在 resource 文件夹下放好 source/ssl证书.jks。
检查端口占用情况,有占用可以 kill:
sudo lsof -i :9000
后台常驻启动 jar 包命令,输出日志到 workspace.log
nohup java -jar workspace-0.0.1-SNAPSHOT.jar > workspace.log 2>&1 &
ps aux | grep workspace-0.0.1-SNAPSHOT.jar
检查java后台
ps aux | grep java
检查服务器监听的网络,显示 tcp 是 ipv4,tcp6 是 ipv6,一般只显示一个 ipv6 是可以兼容 ipv4 的:
sudo netstat -tuln | grep 9000
服务器本地测试请求和 ssl 握手:
curl -k https://127.0.0.1:9000/workspace/api/users/login \
-H "Content-Type: application/json" \
-d '{"ssno":"SX2316098","pwd":"123"}' \
-X POST
服务器本地跨域测试:
curl -i -k https://localhost:9000/workspace/api/users/login \
-X POST \
-H "Content-Type: application/json" \
-H "Origin: https://www.baidu.com" \
-d '{"ssno":"SX2316098","pwd":"123"}'
ssl 握手测试:
openssl s_client -connect 127.0.0.1:9000
成功会输出:
SSL handshake has read 1234 bytes and written 567 bytes