Springboot3+Vue3+MybatisPlus+Vite+TS+ElementPlus搭建网站


趁着寒假充实一下技能…

顺便更新一下自己的技术栈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 层处理业务逻辑,而 MapperRepository 层负责数据访问。下面是这些层级的一般职责:

  • 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 的 IServiceServiceImpl 来扩展基本的 CRUD 功能。但请注意,即使使用了 IServiceServiceImpl,也不会自动生成特定的方法,如 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。HandlerInterceptorHandshakeInterceptor 可以直接注入 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 事务性操作
代码层锁 synchronizedReentrantLock 单机多线程
锁粒度 粗粒度锁 锁整个资源(如整张表) 简单操作
细粒度锁 锁资源子集(如某行数据) 高并发复杂操作

异步处理

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)

  • 目的:处理底层数据读写(如文件、网络、设备输入输出)。
  • 分类:
    • 字节流:处理原始字节(InputStreamOutputStream)。
    • 字符流:处理文本数据(ReaderWriter)。

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


文章作者: Alex Lee
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Alex Lee !
评论
  目录