2024年2月

1 背景

我们之前介绍过,随着业务流量上涨之后,我们的系统需要适时的进行扩容。
数据存储层我们也介绍过MySQL的扩容 Scale UP(纵向扩展) 和 Scale Out(横向扩展)

  • 垂直拆分(Scale Up 纵向扩展):包括垂直分库、垂直分表
  • 水平拆分(Scale Out 横向扩展):包括库内分表、分库分表

详细可以参考笔者这几篇:

MySQL全面瓦解28:分库分表


MySQL全面瓦解29:分库分表之Partition功能详解


数据库系列:数据库高可用及无损扩容

存储层有高效动态的扩缩容机制,那计算服务也应该同步有这种能力,这样才能保证流量上涨时能够快速扩容。
如下图,
蓝色部件是扩容的部分
,每一分层都有自己的扩容机制。
image

现在服务大都为云上服务,所以接下来我们详细介绍下Kubernetes中的HPA和VPA。

2 容器扩缩容介绍

2.1 先看下 HPA 和 VPA 的概念

VPA(Vertical Pod Autoscaler)和HPA(Horizontal Pod Autoscaler)都是Kubernetes中的自动扩展功能,但它们的作用对象和扩展方式不同。

HPA(纵向)的作用对象是Pod的数量。
它通过监控CPU使用率、内存使用率等指标,根据预设的阈值自动调整Pod的数量(即当个集群副本的数量)。
当Pod的CPU和内存使用率超过阈值时,HPA会自动增加Pod的数量;当使用率下降时,HPA会自动减少Pod的数量。

VPA的作用对象是Pod的资源需求,如CPU、内存等。
它通过监控Pod的资源使用情况,根据实际的资源需求自动调整Pod的资源限制和请求。
当Pod的资源使用率高于资源请求时,VPA会自动增加Pod的资源Request和Limit;当资源使用率下降时,VPA会自动减少Pod的资源Requist和Limit。
这种方式可以帮助优化资源利用率,避免资源浪费。

2.2 HPA实现详解

HPA(纵向)的作用对象是Pod的数量。可针对CPU使用率、内存使用率进行扩缩容配置,会根据期望指标,会触发扩容或者缩容,若同时有多个指标,会取Max值。

如下图:
image

解读下:

  1. 设计HPA资源,包含实现扩容的条件
  2. 策略Yaml应用到HPA控制器中
  3. HPA控制器监听Prometheus,获取多个Metrix指标,如内存、CPU、连接数等
  4. 当监听的数据值大于等于扩容条件的阈值的时候,执行扩容操作
  5. 修改 replication controller, deployment, replica set, stateful set 中的pod数量
  6. 控制器定期调整 副本控制器(replica set) 或 部署(deploy) 中副本的数量

算法解析:

desiredReplicas = ceil[currentReplicas * ( currentMetricValue / desiredMetricValue )]

即 (当前指标值 ➗ 期望指标值) ✖️ 当前副本数 ,结果再向上取整,最终结果就是期望的副本数量

举例说明:

apiVersion: autoscaling/v2beta2  
kind: HorizontalPodAutoscaler  
metadata:  
  name: hpa-example  
spec:  
  scaleTargetRef:  
    apiVersion: apps/v1  
    kind: Deployment  
    name: hpa-example  
  minReplicas: 10  # Pod副本数下限
  maxReplicas: 20  # Pod副本数上限
  metrics:  
  - type: Resource  
    resource:  
      name: cpu  
      target:  
        type: Utilization  
        averageUtilization: 60  # 当CPU利用率大于60%或者内存占用大于60%的时候,执行调整
  1. 假设当前指标:CPU利用率 70%
  2. 期望指标:CPU利用率60%
  3. 假设当前副本数:10
  4. 期望副本数:ceil(10*(70/60))=12
  5. 最终总的Pod副本数应该是12,即扩容数量为2
  6. 注意扩容和缩容幅度不能超过 HorizontalPodAutoscaler 资源中的副本上下限

2.3 VPA实现详解

因为VPA影响的是Pod的Request和Limit这两个参数,所以我们先了解下这两个属性的含义。
1. Request表示容器在运行过程中所需的最小资源量,即容器启动时的资源保证。
当Pod被调度到节点上时,Kubernetes会确保该节点上至少有足够的资源来满足Pod中所有容器的Request值。如果节点的可用资源无法满足Pod的Request值,则不会被调度到该节点上,而是保持在Pending状态,直到找到满足条件的节点为止。
因此,合理设置Request值可以确保Pod的稳定性和可靠性。

2. Limit则表示容器在运行过程中可以使用的最大资源量,即容器可以消耗的资源上限。
如果容器的实际资源使用量超过了Limit值,Kubernetes会采取相应的措施来限制容器的资源使用,例如限制CPU使用率、杀死进程等,以确保不会因资源不足而导致系统崩溃或性能下降。
通过设置Limit值,可以有效地防止单个容器或整个Pod占用过多的资源,从而影响整个集群的性能和稳定性。

apiVersion: v1
kind: Pod
  - apiVersion: v1
  name: xx-default-756gh8588cc
  namespace: xx-ns
spec:  
  containers:    
    name: xx-default
    resources:
      limits:  # 对应上限最大值
        cpu: "2"
        memory: 8G
      requests:  # 对应下限最小值
        cpu: 200m
        memory: 500Mi

而 VPA 是Kubernetes的一种自动调整Pod资源请求(Request)配额的工具。通过VPA,您可以根据Pod的实际资源使用情况动态地调整其CPU和内存资源请求(Request),以实现更精细的资源管理和更好的资源利用率。
简单理解就是 取服务一定周期内(如8天内) CPU 和内存资源用量的 TP90 分位数据作为推荐值

可以参考kubernetes的算法

举例说明:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: myapp-vpa-default
  namespace: xx-ns
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp-vpa-default
  updatePolicy:
    updateMode: "Off"  # 不直接进行修改
status:
  recommendation:
    containerRecommendations:
    - containerName: msg-index-default
      lowerBound: # 下限值
        cpu: 480m
        memory: "7117753323"
      target:  # 推荐值
        cpu: 930m
        memory: "8701517761"
      uncappedTarget:
        cpu: 930m
        memory: "8701517761"
      upperBound: # 上限值
        cpu: 1100m
        memory: "8841153193"

在这个配置中,我们定义了一个名为myapp-vpa的VPA,它将调整名为myapp的Deployment中的Pod的资源。
从输出信息可以看出,VPA对Pod给出了推荐值:Cpu: 930m,Memory: 8Gi,因为 updatePolicy 策略设置了updateMode: "Off",所以不会主动更新Pod。

上面的案例可以通过下面的图进行演进:通过VPA的推荐,我们将Request值从 200m的CPU 和 500Mi 的内存,调整为 1 Core 的CPU 和 8Gi 的内存。
image
调优完成之后,资源分配更加合理,资源更充足,性能、稳定性更高。

3 总结

总的来说,HPA是通过自动调整Pod的数量来应对负载压力,而VPA是通过自动调整Pod的资源需求来优化资源利用率。它们可以配合使用,实现更加精细的自动扩展策略。保证流量动态变化的业务场景中实现资源的弹性伸缩,实现资源的最优合理性利用。

三分钟,迎接一个更加高效和简便的开发体验。

在快节奏的软件开发领域,每一个简化工作流程的机会都不容错过。想要一个无需繁琐配置、能够迅速启动的数据持久化方案吗?这篇文章将是你的首选攻略。在这里,我们将向你展示如何将
Spring Boot
的便捷性、
JPA
的强大查询能力和
SQLite
的轻量级特性结合在一起,实现快速而又优雅的数据管理。

为什么选择 SQLite

SQLite
是一个用 C 语言编写的开源、轻量级、快速、独立且高可靠性的 SQL 数据库引擎,它提供了功能齐全的数据库解决方案。对于大多数的应用,
SQLite
都可以满足。使用 SQLite 可以
零配置
启动,对于小型应用或者快速原型设计是一个非常大的优势。

使用 SQLite 具有下面几个优点:

  1. 轻量级:SQLite很小巧,不需要独立服务器,便于集成到应用中。
  2. 零配置:启用 SQLite 无需复杂配置,只需指定一个文件路径存放 DB 文件,简化了数据库的设置流程。
  3. 便于移植:数据库是单一文件,方便备份和在不同环境间迁移。
  4. 跨平台:SQLite 支持各种操作系统,容易实现应用的跨平台运行。
  5. 性能良好:对于小型应用,SQLite 提供足够的读写性能。
  6. 遵循ACID:SQLite事务符合ACID原则,数据操作可靠。
  7. 社区支持:虽然简单,但拥有强大的社区和广泛的文档资源。

之前写过一篇
SQLite 入门教程 (https://www.wdbyte.com/db/sqlite/)
,感情的同学可以参考。

为什么 选择 JPA

Spring Data JPA
是Spring Data项目的一部分,旨在简化基于JPA(Java Persistence API)的数据访问层(Repository层)的实现。JPA是一种 ORM(对象关系映射)规范,它允许开发者以面向对象的方式来操作数据库,

通常应用程序实现数据访问层可能非常麻烦,必须编写太多的样板代码才能实现简单的查询,更不用说分页等其他操作,而 Spring Data JPA 可以让开发者非常容易地实现对数据库的各种操作,显著减少实际需要的工作量。

详细介绍 JPA 并不是本文目的,关于 JPA 的更多内容可以访问:

  1. Spring Data JPA 官网:https://spring.io/projects/spring-data-jpa
  2. Spring Boot 使用 Spring Data JPA

创建 Spring Boot 项目

用于后续演示,首先创建一个简单的 Spring Boot 项目。你可以自由创建,或者使用 Spring 官网提供的快速创建工具:
https://start.spring.io/

注意,文章示例项目使用 Java 21 进行演示。

为了方便开发,创建一个基础的 Spring Boot 项目后,添加以下依赖。

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 从 Hibernate 6 开始,支持 SQLite 方言。-->
<!-- https://mvnrepository.com/artifact/org.hibernate.orm/hibernate-community-dialects -->
<dependency>
	<groupId>org.hibernate.orm</groupId>
	<artifactId>hibernate-community-dialects</artifactId>
	<version>6.4.3.Final</version>
</dependency>
<!-- sqlite jdbc 驱动 -->
<dependency>
	<groupId>org.xerial</groupId>
	<artifactId>sqlite-jdbc</artifactId>
	<version>3.45.1.0</version>
</dependency>
<!-- Lombok 简化 get set tostring log .. -->
<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<optional>true</optional>
</dependency>
<!-- apache java 通用工具库 -->
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-lang3</artifactId>
	<version>3.13.0</version>
</dependency>
<!-- 编码通用工具库 -->
<dependency>
	<groupId>commons-codec</groupId>
	<artifactId>commons-codec</artifactId>
	<version>1.16.0</version>
</dependency>

配置 SQLite & JPA

在 Spring Boot 中,对 SQLite 的配置非常简单,只需要指定一个位置存放 SQLite 数据库文件。SQLite 无服务端,因此可以直接启动。

spring.datasource.url=jdbc:sqlite:springboot-sqlite-jpa.db
spring.datasource.driver-class-name=org.sqlite.JDBC
# JPA Properties
spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
# create 每次都重新创建表,update,表若存在则不重建
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

配置实体映射

在使用 JPA 开发时,就是使用
jakarta.persistence
包中的注解配置 Java 实体类和表的映射关系,比如使用
@Table
指定表名,使用
@Column
配置字段信息。

import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Entity
@Getter
@Setter
@ToString
@Table(name = "website_user")
public class WebsiteUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "username", nullable = false, unique = true, length = 64)
    private String username;

    @Column(name = "password", nullable = false, length = 255)
    private String password;

    @Column(name = "salt", nullable = false, length = 16)
    private String salt;

    @Column(name = "status", nullable = false, length = 16, columnDefinition = "VARCHAR(16) DEFAULT 'active'")
    private String status;

    @Column(name = "created_at", nullable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
    private LocalDateTime createdAt;
  
    @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
    private LocalDateTime updatedAt;
}

编写 JPA 查询方法

Spring Data JPA 提供了多种便捷的方法来实现对数据库的查询操作,使得能够以非常简洁的方式编写对数据库的访问和查询逻辑。比如 Spring Data JPA 允许通过在接口中定义遵循一定命名方法的方式来创建数据库查询。如
findByName
将生成一个根据
name
查询指定实体的 SQL。

代码示例:

@Repository
public interface WebsiteUserRepository extends CrudRepository<WebsiteUser, Long> {

    /**
     * 根据 username 查询数据
     * @param name
     * @return
     */
    WebsiteUser findByUsername(String name);
}

代码示例中,继承的
CrudRepository
接口中包含了常见的 CURD 操作方法。自定义的
findByUsername
方法可以根据
WebsiteUser
中的 Username 进行查询。

编写 Controller

编写三个 API 用来演示 Spring Boot 结合 SQLite 以及 JPA 是否成功。

初始化方法
init()

  • 映射到
    "/sqlite/init"
    的 GET请求。

  • 创建了10个
    WebsiteUser
    实体,为每个用户生成随机的用户名和盐值,并用MD5加密其密码("123456" + 盐)。

  • 用户信息包括用户名、加盐后的密码、创建和更新的时间戳,以及用户状态。

  • 用户信息被保存到数据库中,并记录日志。

查找用户方法
findByUsername(String username)

  • 映射到
    "/sqlite/find"
    的GET请求。
  • 通过用户名查询用户。如果找到,返回用户的字符串表示;否则返回
    null

登录方法
findByUsername(String username, String password)

  • 映射到
    "/sqlite/login"
    的GET请求。
  • 验证传入的用户名和密码。首先通过用户名查询用户,然后将传入的密码与盐值结合,并与数据库中存储的加盐密码进行MD5加密比对。
  • 如果密码匹配,则认证成功,返回 "login succeeded";否则,返回 "login failed"。

代码示例:

import java.time.LocalDateTime;
import com.wdbyte.springsqlite.model.WebsiteUser;
import com.wdbyte.springsqlite.repository.WebsiteUserRepository;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
 * @author https://www.wdbyte.com
 */
@Slf4j
@RestController
public class SqliteController {

    @Autowired
    private WebsiteUserRepository userRepository;

    @GetMapping("/sqlite/init")
    public String init() {
        for (int i = 0; i < 10; i++) {
            WebsiteUser websiteUser = new WebsiteUser();
            // 随机4个字母
            websiteUser.setUsername(RandomStringUtils.randomAlphabetic(4));
            // 随机16个字符用于密码加盐加密
            websiteUser.setSalt(RandomStringUtils.randomAlphanumeric(16));
            String password = "123456";
            // 密码存储 = md5(密码+盐)
            password = password + websiteUser.getSalt();
            websiteUser.setPassword(DigestUtils.md5Hex(password));
            websiteUser.setCreatedAt(LocalDateTime.now());
            websiteUser.setUpdatedAt(LocalDateTime.now());
            websiteUser.setStatus("active");
            WebsiteUser saved = userRepository.save(websiteUser);
            log.info("init user {}", saved.getUsername());
        }
        return "init success";
    }

    @GetMapping("/sqlite/find")
    public String findByUsername(String username) {
        WebsiteUser websiteUser = userRepository.findByUsername(username);
        if (websiteUser == null) {
            return null;
        }
        return websiteUser.toString();
    }

    @GetMapping("/sqlite/login")
    public String findByUsername(String username, String password) {
        WebsiteUser websiteUser = userRepository.findByUsername(username);
        if (websiteUser == null) {
            return "login failed";
        }
        password = password + websiteUser.getSalt();
        if (StringUtils.equals(DigestUtils.md5Hex(password), websiteUser.getPassword())) {
            return "login succeeded";
        } else {
            return "login failed";
        }
    }
}

至此,项目编写完成,完整目录结构如下:

├── pom.xml
└── src
    ├── main
        ├── java
        │   └── com
        │       └── wdbyte
        │           └── springsqlite
        │               ├── SpringBootSqliteApp.java
        │               ├── controller
        │               │   └── SqliteController.java
        │               ├── model
        │               │   └── WebsiteUser.java
        │               └── repository
        │                   └── WebsiteUserRepository.java
        └── resources
            ├── application.properties
            ├── static
            └── templates

启动测试

Spring Boot 启动时由于库表不存在,自动创建库表:

Hibernate: create table website_user (id integer, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP not null, password varchar(255) not null, salt varchar(16) not null, status VARCHAR(16) DEFAULT 'active' not null, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP not null, username varchar(64) not null unique, primary key (id))
Hibernate: alter table website_user drop constraint UK_61p1pfkd4ht22uhlib72oj301
2024-02-27T20:00:21.279+08:00  INFO 70956 --- [main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2024-02-27T20:00:21.578+08:00  WARN 70956 --- [main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2024-02-27T20:00:21.931+08:00  INFO 70956 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path ''
2024-02-27T20:00:21.938+08:00  INFO 70956 --- [main] c.w.springsqlite.SpringBootSqliteApp     : Started SpringBootSqliteApp in 3.944 seconds (process running for 5.061)

请求初始化接口

$ curl http://127.0.0.1:8080/sqlite/init
init success

可以看到输出日志成功写入了 10条数据,且输出了
username
值。

2024-02-27T20:01:04.120+08:00 ...SqliteController : init user HUyz
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.123+08:00 ...SqliteController : init user ifQU
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.126+08:00 ...SqliteController : init user GBPK
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.129+08:00 ...SqliteController : init user rytE
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.132+08:00 ...SqliteController : init user iATH
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.134+08:00 ...SqliteController : init user ZQRW
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.137+08:00 ...SqliteController : init user cIPM
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.140+08:00 ...SqliteController : init user MemS
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.143+08:00 ...SqliteController : init user GEeX
Hibernate: insert into website_user (created_at,password,salt,status,updated_at,username) values (?,?,?,?,?,?)
Hibernate: select last_insert_rowid()
2024-02-27T20:01:04.146+08:00 ...SqliteController : init user ZQrT

请求查询用户接口

$ curl http://127.0.0.1:8080/sqlite/find\?username\=ZQrT
WebsiteUser(id=10, username=ZQrT, password=538ea3b5fbacd1f9354a1f367b36135a, salt=RxaivBHlyJCxtOEv, status=active, createdAt=2024-02-27T20:01:04.144, updatedAt=2024-02-27T20:01:04.144)

查询成功,回显了查询到的用户信息。

请求登录接口

在初始化数据时,密码统一配置为 123456,下面的测试可以看到使用正确的密码可以通过校验。

$ curl http://127.0.0.1:8080/sqlite/login\?username\=ZQrT\&password\=123456
login succeeded
$ curl http://127.0.0.1:8080/sqlite/login\?username\=ZQrT\&password\=12345
login failed

SQLite 3 数据审查

使用 Sqlite3 命令行工具查看 SQLite 数据库内容。

$ ./sqlite3 springboot-sqlite-jpa.db
SQLite version 3.42.0 2023-05-16 12:36:15
Enter ".help" for usage hints.
sqlite> .tables
website_user

sqlite> .mode table
sqlite> select * from website_user;
+----+---------------+----------------------------------+------------------+--------+---------------+----------+
| id |  created_at   |             password             |       salt       | status |  updated_at   | username |
+----+---------------+----------------------------------+------------------+--------+---------------+----------+
| 1  | 1709035264074 | 4b2b68c0df77669540fc0a487d753400 | 1njFP8ykWmlu01Z8 | active | 1709035264074 | HUyz     |
| 2  | 1709035264120 | 7e6444d57f753cfa6c1592a17e68e66e | 9X3El5jQaMhrROSf | active | 1709035264120 | ifQU     |
| 3  | 1709035264124 | 1d24c4ddb351eb56f665adb13708f981 | Jn9IrT6MYqVqzpu8 | active | 1709035264124 | GBPK     |
| 4  | 1709035264126 | 960747cc48aeed71e8ff714deae42e87 | wq8pb1G9pIalGHwP | active | 1709035264126 | rytE     |
| 5  | 1709035264129 | cf1037b95a997a1b1b9d9aa598b9f96b | An0hwV2n9cN4wpOy | active | 1709035264129 | iATH     |
| 6  | 1709035264132 | b68d42108e5046bd25b74cda947e0ffc | EozfDAkpn5Yx4yin | active | 1709035264132 | ZQRW     |
| 7  | 1709035264134 | 78d4841af9a12603204f077b9bf30dcc | 2FRNQ2zWksJHOyX9 | active | 1709035264135 | cIPM     |
| 8  | 1709035264137 | 60b8051ca3379c569a3fb41ed5ff05aa | KpT3IGwWmhlWIUq7 | active | 1709035264137 | MemS     |
| 9  | 1709035264140 | 0ca0a2dce442315c11f5488c0127f905 | RhGOYnNEMYbnWoat | active | 1709035264140 | GEeX     |
| 10 | 1709035264144 | 538ea3b5fbacd1f9354a1f367b36135a | RxaivBHlyJCxtOEv | active | 1709035264144 | ZQrT     |
+----+---------------+----------------------------------+------------------+--------+---------------+----------+
sqlite> 

一如既往,文章中代码存放在
Github.com/niumoo/javaNotes
.

参考

本文
Github.com/niumoo/JavaNotes
仓库已经收录。
本文原发于网站:
Spring Boot 整合 SQLite 和 JPA
本文原发于公众号:
三分钟数据持久化:Spring Boot, JPA 与 SQLite 的完美融合

欢迎来到从零开始学Spring Boot的旅程!在Spring Boot中,返回JSON数据是很常见的需求,特别是当我们构建RESTful API时。我们对上一篇的Hello World进行简单的修改。

  1. 添加依赖
    首先,确保你的build.gradle文件中已经包含了Spring Web的依赖,因为返回JSON数据通常与Web请求和响应有关。Spring Boot的starter-web已经包含了所有必要的依赖,所以如果你使用Spring Initializr创建的项目,这一步应该已经完成了。
    20240228210905

  2. 创建数据模型
    接下来,我们需要一个数据模型来表示我们要返回的JSON数据。先创建一个model的package来存放java类,创建一个User的Java类来表示这个模型。

     package cn.daimajiangxin.springboot.learning.model;
    
     import lombok.AllArgsConstructor;
     import lombok.Data;
     import lombok.extern.slf4j.Slf4j;
    
     @Data
     @Slf4j
     @AllArgsConstructor
     public class User {
         private String name;
         private int age;
     }
    
    
  3. 创建控制器
    创建一个控制器来处理HTTP请求并返回JSON数据。使用@RestController和@RequestMapping注解来标记这个类,这样Spring Boot就会自动将返回的对象转换为JSON格式。@RequestMapping注解对请求处理类中的请求处理方法进行标注,@GetMapping注解用于处理HTTP GET请求,并将请求映射到具体的处理方法中。

    package cn.daimajiangxin.springboot.learning.controller;
    
    import cn.daimajiangxin.springboot.learning.model.User;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @RequestMapping("/user")
    public class UserController {
    
        @GetMapping("/getUser")
        public User getUser() {
            // 创建一个User对象
            User user = new User("Alice", 30);
            return user; // Spring Boot会自动将User对象转换为JSON格式
        }
    }
    
    
  4. 运行应用程序
    启动你的Spring Boot应用程序。如果一切顺利,当你访问
    http://localhost:8080/user/getUser
    时,你应该能看到如下的JSON响应:
    20240229084207

  5. 自定义JSON输出
    有时,你可能需要自定义JSON的输出格式或者包含额外的信息。你可以使用@JsonInclude、@JsonProperty等注解来控制Jackson库(Spring Boot默认的JSON处理库)的序列化行为。例如,你可以使用@JsonInclude(JsonInclude.Include.NON_NULL)来避免输出null值,或者使用@JsonProperty来重命名JSON字段。

    import com.fasterxml.jackson.annotation.JsonInclude;
    import com.fasterxml.jackson.annotation.JsonProperty;
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.extern.slf4j.Slf4j;
    
    @Data
    @Slf4j
    @AllArgsConstructor
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public class User {
        @JsonProperty("username")
        private String name;
        private int age;
    }
    

    这样,返回的JSON数据就会将name字段重命名为username,并且不会包含null值。

    20240229084659

  6. 总结
    在Spring Boot中返回JSON数据是一个简单而直接的过程。通过使用@RestController注解和创建适当的数据模型,你可以很容易地构建RESTful API并返回格式化的JSON响应。记住,你可以使用Jackson库的注解来进一步定制JSON的输出格式。


我是代码匠心,和我一起学习更多精彩知识!!!扫描二维码!关注我,实时获取推送。

公众号


源文来自:
https://daimajiangxin.cn

Elasticsearch 是一个使用 Java 语言编写、遵守 Apache 协议、支持 RESTful 风格的分布式全文搜索和分析引擎,它基于 Lucene 库构建,并提供多种语言的 API。Elasticsearch 可以对任何类型的数据进行索引、查询和聚合分析,无论是文本、数字、地理空间、结构化还是非结构化的。Elasticsearch 的核心功能是搜索,它可以对数据进行分词匹配、相关性评分、高亮显示等操作,返回相关度高的结果列表。Elasticsearch 也可以用作数据分析,它可以对数据进行统计、分类、聚类等操作,返回聚合结果或图表。

本文将用我开源的 waynboot-mall 项目作于代码讲解,Elasticsearch 版本是 7.10.1。

waynboot-mall 是一套全部开源的微商城项目,包含三个项目:
运营后台、H5 商城和后端接口
。实现了一套完整的商城业务,有首页展示、商品分类、商品详情、sku 详情、商品搜索、加入购物车、结算下单、支付宝/微信支付、订单列表、商品评论等一系列功能。

本文大纲如下,

image

应用场景

Elasticsearch 的典型应用场景有以下几种:

  • 全文搜索:Elasticsearch 提供了全文搜索的功能,适用于电商商品搜索、App 搜索、企业内部信息搜索、IT 系统搜索等。例如我们可以为每一个商品作为文档保存进 Elasticsearch,然后使用 Elasticsearch 的查询语言来对文档进行分词匹配、相关性评分、高亮显示等操作,返回相关度高的结果列表。
  • 日志分析:Elasticsearch 可以用来收集、存储和分析海量的日志数据,如项目日志、Nginx log、MySQL Log 等,往往很难从繁杂的日志中获取有价值的信息。Elasticsearch 能够借助 Beats、Logstash 等工具快速对接各种常见的数据源,并通过集成的 Kibana 高效地完成日志的可视化分析,让日志产生价值。
  • 运维监控:Elasticsearch 也可以用来监控和管理 IT 系统的运行状态和性能指标,如 CPU、内存、磁盘、网络等。可以使用 Beats、Logstash 将这些数据实时采集并索引到 Elasticsearch 中,然后通过 Kibana 构建自定义的仪表盘和告警规则,实现实时的运维监控和预警。
  • 数据可视化:Elasticsearch 与 Kibana 的结合提供了强大的数据可视化能力,可以使用 Kibana 来创建各种类型的图表和仪表盘,展示 Elasticsearch 中存储或聚合的数据,如直方图、饼图、地图、时间线等。还可以使用 Kibana 的 Canvas 功能来制作动态的数据展示页面,或者使用 Kibana 的 Lens 功能来进行交互式的数据探索。

waynboot-mall 商城选择使用 Elasticsearch 作为搜索引擎,负责对商品数据进行索引和检索,选择 Elasticsearch 的原因有以下几点,

  1. Elasticsearch 是一个开源的分布式搜索引擎,基于 Lucene 开发,支持全文检索、结构化检索、地理位置检索等多种类型的检索,功能丰富。
  2. Elasticsearch 本身具有高性能和高可用性的设计,可以通过集群和分片机制实现水平扩展,支持海量数据的存储和处理,适合大规模的商城搜索场景。
  3. Elasticsearch 网上社区活跃,现有互联网上有大量的使用文档和案例,方便入门使用和问题排查。
  4. Elasticsearch 有众多分词器插件,关于中文分词器的使用非常成熟,拿来即用,支持自定义字典等。

waynboot 项目使用的 Elasticsearch 插件

Elasticsearch 的插件非常丰富,我给大家介绍其中 waynboot 项目使用的 Elasticsearch 插件。

IK Analyzer

IK Analyzer 是一个开源的中文分词器,由阿里巴巴集团发布。它采用了细粒度切分和歧义处理等技术,能够较好地处理各种中文文本。IK Analyzer 支持普通模式、搜索模式和拼音模式三种分词方式,并可以根据需要自定义字典。

Pinyin Analyzer

Pinyin Analyzer 插件是一个用于将中文字符转换为拼音的插件,它集成了 NLP 工具(nlp-lang)。该插件包含了分析器:pinyin,分词器:pinyin 和 token-filter:pinyin。该插件还提供了一些可选的参数,可以控制拼音的输出格式,例如是否保留首字母,是否保留全拼,是否保留非中文字符等。

目录结构

在 waynboot-mall 项目中,给 Elasticsearch 定义了专门的数据访问层 waynboot-data-elastic,该层目录结构如下,

    |-- waynboot-data                    // 数据访问层
    |   |-- waynboot-data-elastic        // Elasticsearch访问配置模块
    |       |-- config
    |       |-- constant
    |       |-- mananger

包目录说明如下,

  • config:Elasticsearch 相关的配置类,包含 ElasticConfig 连接配置类 以及 ElasticClientConfig 客户端配置相关类,ElasticClientConfig 类可以设置访问密码。
  • constants:Elasticsearch 访问层的相关常量类,这里面定义了商品同步数据的索引名称等信息。
  • mananger:Elasticsearch 访问层的相关操作类,定义了 ElasticDocument 文档操作类,用于操作 Elasticsearch。

代码实战

在 waynboot-mall 项目中,Elasticsearch 主要用于支持首页商品的分词搜索、分页排序等功能。Elasticsearch 版本是 7.0,以下实战讲解都是在 7.0 版本基础上进行。

要使用 Elasticsearch ik 分词器进行中文分词搜索,首先需要安装相应的插件 elasticsearch-analysis-ik,然后在创建索引时指定使用中文分词器作为字段的 analyzer 属性。

在日常对 Elasticsearch 的操作中,我们可以通过 rest api 的方式进行操作。

Elasticsearch rest api 操作

如下我们可以创建一个索引名称为 goods,包含两个属性 title、content。并且 这两个属性都使用 ik 分词器。注意这里我用的 Elasticsearch 提供 Rest api 方式创建索引。

    PUT /goods
    {
        "settings": {
            "index": {
                "number_of_shards": 1,
                "number_of_replicas": 0
            }
        },
        "mappings": {
            "properties": {
                "title": {
                    "type": "text",
                    "analyzer": "ik_max_word"
                },
                "content": {
                    "type": "text",
                    "analyzer": "ik_max_word"
                }
            }
        }
    }

创建索引后,就可以向索引中添加两条数据,例如:

    POST /books/_doc/1
    {
        "title": "格林童话",
        "content": "这本书介绍了很多童话故事,有白雪公主、狮子王、美人鱼等。"
    }

    POST /books/_doc/2
    {
        "title": "中国童话故事",
        "content": "这本书介绍了很多中国童话故事。"
    }

然后我们就可以使用 match 语法来进行中文分词检索,这里我查询 goods 索引中,title 属性是 "动画" 的记录。如下,

    GET /books/_search
    {
        "query":{
            "match":{
                "title": "童话"
            }
        }
    }

查询结果如下,

    {
        "took": 0,
        "timed_out": false,
        "_shards": {
            "total": 1,
            "successful": 1,
            "skipped": 0,
            "failed": 0
        },
        "hits": {
            "total": {
                "value": 2,
                "relation": "eq"
            },
            "max_score": 0.66666690013,
            "hits": [
                {
                    "_index": "books",
                    "_type": "_doc",
                    "_id": "1",
                    "_score": 0.66666690013,
                    "_source": {
                        "title": "格林童话",
                        "content": "这本书介绍了很多童话故事,有白雪公主、狮子王、美人鱼等。"
                    }
                },
                {
                    "_index": "books",
                    "_type": "_doc",
                    "_id": "2",
                    "_score": 0.099543065,
                    "_source": {
                        "title": "中国童话故事",
                        "content": "这本书介绍了很多中国童话故事。"
                    }
                }
            ]
        }
    }

可以看到,查询结果中匹配了标题包含“童话”的文档,这说明 Elasticsearch 使用了中文分词器对查询字符串和文档进行了分词,并根据相关性得分返回了结果。

全文搜索以及筛选排序

在 waynboot-mall 项目中,商城首页顶部提供了商品搜索栏,用户可以输入商品名称搜索自己想要的商品,搜索结果展示后,还可以进行热门、新品过滤以及价格、销量等进行排序。

image

可以看到搜索功能还是比较复杂的,在 waynboot-mall 项目中,这些逻辑全部在 Elasticsearch 内部进行处理,代码如下,

    @RestController
    @AllArgsConstructor
    @RequestMapping("search")
    public class SearchController extends BaseController {
        private IGoodsService iGoodsService;
        private ElasticDocument elasticDocument;

        @GetMapping("result")
        public R result(SearchVO searchVO) throws IOException {
            // 获取筛选、排序条件
            Long memberId = MobileSecurityUtils.getUserId();
            String keyword = searchVO.getKeyword();
            Boolean filterNew = searchVO.getFilterNew();
            Boolean filterHot = searchVO.getFilterHot();
            Boolean isNew = searchVO.getIsNew();
            Boolean isHot = searchVO.getIsHot();
            Boolean isPrice = searchVO.getIsPrice();
            Boolean isSales = searchVO.getIsSales();
            String orderBy = searchVO.getOrderBy();
            SearchHistory searchHistory = new SearchHistory();
            if (memberId != null && StringUtils.isNotEmpty(keyword)) {
                searchHistory.setCreateTime(LocalDateTime.now());
                searchHistory.setUserId(memberId);
                searchHistory.setKeyword(keyword);
            }
            Page<SearchVO> page = getPage();
            // 查询包含关键字、已上架商品
            SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
            BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
            MatchQueryBuilder matchFiler = QueryBuilders.matchQuery("isOnSale", true);
            MatchQueryBuilder matchQuery = QueryBuilders.matchQuery("name", keyword);
            MatchPhraseQueryBuilder matchPhraseQueryBuilder = QueryBuilders.matchPhraseQuery("keyword", keyword);
            boolQueryBuilder.filter(matchFiler).should(matchQuery).should(matchPhraseQueryBuilder).minimumShouldMatch(1);
            searchSourceBuilder.timeout(new TimeValue(10, TimeUnit.SECONDS));
            // 按是否新品排序
            if (isNew) {
                searchSourceBuilder.sort(new FieldSortBuilder("isNew").order(SortOrder.DESC));
            }
            // 按是否热品排序
            if (isHot) {
                searchSourceBuilder.sort(new FieldSortBuilder("isHot").order(SortOrder.DESC));
            }
            // 按价格高低排序
            if (isPrice) {
                searchSourceBuilder.sort(new FieldSortBuilder("retailPrice").order("asc".equals(orderBy) ? SortOrder.ASC : SortOrder.DESC));
            }
            // 按销量排序
            if (isSales) {
                searchSourceBuilder.sort(new FieldSortBuilder("sales").order(SortOrder.DESC));
            }
            // 筛选新品
            if (filterNew) {
                MatchQueryBuilder filterQuery = QueryBuilders.matchQuery("isNew", true);
                boolQueryBuilder.filter(filterQuery);
            }
            // 筛选热品
            if (filterHot) {
                MatchQueryBuilder filterQuery = QueryBuilders.matchQuery("isHot", true);
                boolQueryBuilder.filter(filterQuery);
            }

            // 组装Elasticsearch查询条件
            searchSourceBuilder.query(boolQueryBuilder);
            // Elasticsearch分页相关
            searchSourceBuilder.from((int) (page.getCurrent() - 1) * (int) page.getSize());
            searchSourceBuilder.size((int) page.getSize());
            // 执行Elasticsearch查询
            List<JSONObject> list = elasticDocument.search("goods", searchSourceBuilder, JSONObject.class);
            List<Integer> goodsIdList = list.stream().map(jsonObject -> (Integer) jsonObject.get("id")).collect(Collectors.toList());
            if (goodsIdList.isEmpty()) {
                return R.success().add("goods", Collections.emptyList());
            }
            // 根据Elasticsearch中返回商品ID查询商品详情并保持es中的排序
            List<Goods> goodsList = iGoodsService.searchResult(goodsIdList);
            Map<Integer, Goods> goodsMap = goodsList.stream().collect(Collectors.toMap(goods -> Math.toIntExact(goods.getId()), o -> o));
            List<Goods> returnGoodsList = new ArrayList<>(goodsList.size());
            for (Integer goodsId : goodsIdList) {
                returnGoodsList.add(goodsMap.get(goodsId));
            }
            if (CollectionUtils.isNotEmpty(goodsList)) {
                AsyncManager.me().execute(new TimerTask() {
                    @Override
                    public void run() {
                        searchHistory.setHasGoods(true);
                        iSearchHistoryService.save(searchHistory);
                    }
                });
            }
            return R.success().add("goods", returnGoodsList);
        }
    }

这里对上面商城的搜索代码给大家做一个讲解:

  • 第一步:获取筛选、排序条件
  • 第二步:获取查询条件-用户搜索关键字、商品已上架
  • 第三步:获取排序条件-按是否新品排序、按是否热品排序、按价格高低排序、按销量排序
  • 第四步:获取过滤条件-筛选新品、筛选热品
  • 第五步:组装 Elasticsearch 查询条件以及分页条件
  • 第六步:执行 Elasticsearch 查询操作
  • 第七步:获取 Elasticsearch 中返回的商品 ID ,并根据商品 id 查询商品详情,最后商品保持 es 中的排序

总结一下

本文给大家讲解了 waynboot-mall 项目中对于 elasticsearch 的使用以及代码实战讲解。希望能帮助大家更好理解 elasticsearch,大家在自己的项目中如果要引入 elasticsearch,可以直接参照本文的示例代码即可使用。

想要获取 waynboot-mall 项目源码的同学,可以关注我公众号【程序员 wayn】,发送 waynboot-mall 即可领取。

如果觉得这篇文章写的不错的话,不妨点赞加关注,我会更新更多技术干货、项目教学、经验分享的文章。

前言:

在本篇 Taurus.MVC WebMVC 入门开发教程的第四篇文章中,

我们将学习如何实现数据列表的绑定,通过使用 List<Model> 来展示多个数据项。

我们将继续使用 Taurus.Mvc 命名空间,同时探讨如何在视图中绑定并显示一个 Model 列表。

步骤1:创建 Model

首先,我们需要更新我们的 Model 类,使其能够表示多个数据项。

我们可以继续沿用之前的 User 类,不过这次我们将创建一个包含多个 User 对象的列表。

public classUser
{
public string Name { get; set; }public int Age { get; set; }
}

步骤2:更新控制器

接下来,我们需要更新控制器以支持传递包含多个 User 对象的列表到视图中。

在 HomeController.cs 文件中,修改 Index 方法来创建一个包含多个 User 对象的列表,并传递给视图。

public classHomeController : Taurus.Mvc.Controller
{
public voidIndex()
{
List
<User> userList = new List<User>{new User { Name = "Alice", Age = 25},new User { Name = "Bob", Age = 30},new User { Name = "Charlie", Age = 28}
};

View.SetForeach(userList,
"list");
}

}

我们通过 View 的 SetForeach 方法,来接收列表数据,并同时进行界面渲染。

同时,我们在SetForeach的第二个方法中,指定界面元素的ID,以便我们知道要渲染的是哪个节点。

步骤3:更新视图

现在,我们需要更新视图来显示列表中的每个 User 对象的信息。

在 Index.html 视图文件中,我们可以通过设定ID,并被代码端使用循环来遍历列表,并显示每个 User 对象的姓名和年龄。

<!DOCTYPE html>
<html  xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>欢迎来到 Taurus.MVC WebMVC</title>
</head>
<body>
    <h1>用户列表</h1>
    <ul id="list">
        <li>姓名:${Name},年龄:${Age}</li>
    </ul>
</body>
</html>

在上述代码中,我们使用 同样使用 ${modelName}  CMS 指令来绑定列表数据。

绑定使用的是视图所使用的 Model,其 类型为 List<User>。

然后,通过节点 id 传递到控制器中循环遍历列表中的每个 User 对象,并显示其姓名和年龄。

其循环的内容为id节点的内部文本,因此以下代码将被循环多次并进行替换:

<li>姓名:${Name},年龄:${Age}</li>

步骤4:运行应用程序

最后,运行应用程序并查看页面的呈现效果。

您将看到一个包含多个用户信息的列表被成功显示在页面上。

步骤5:循环嵌套的调用方式

有时候,界面可能涉及循环嵌套,以实现嵌套的结果。

例如:框架后台管理中的 Extend Menu 就用到了这个手法。

界面Html如:

                <div id="menuList">
                    <div style="float:left"><b>${0} :</b></div>
                    <div name="hostList" style="float:left">
                        <a target="frame" href="${HostUrl}">${HostName}</a>&nbsp;&nbsp;|&nbsp;</div>
                    <div style="clear:both;height:5px;">
                    </div>
                </div>

一级节点:MenuList 为一级循环,其内部文本 ${0} 绑定标题。

说明:${0} 用来绑定分类标题,${Model} 语法,也可以根据索引来绑定数据。

二级节点:hostList 为二级循环,其内部文件有多个绑定项:${HostUrl} 和 ${HostName}

对于这种循环嵌套,对新手来手,是需要多习练习适应的。

这里我们看框架内部是怎么实现的:

internal partial classAdminController
{
#region 页面呈现 privateMDataTable menuTable;/// <summary> ///Ext - Menu 展示/// </summary> public voidMenu()
{

menuTable
= newMDataTable();
menuTable.Columns.Add(
"MenuName,HostName,HostUrl");
MDataTable dtGroup
= newMDataTable();
dtGroup.Columns.Add(
"MenuName");

List
<string> groupNames = new List<string>();#region 加载自定义菜单 ......省略代码......#endregionView.OnForeach+=View_OnForeach_Menu;
dtGroup.Bind(View,
"menuList");
}
private string View_OnForeach_Menu(string text, MDictionary<string, string> values, introwIndex)
{
string menu = values["MenuName"];if (!string.IsNullOrEmpty(menu))
{
//循环嵌套:1-获取子数据 MDataTable dt = menuTable.FindAll("MenuName='" + menu + "'");if (dt != null && dt.Rows.Count > 0)
{
//循环嵌套:2 - 转为节点 XmlNode xmlNode = View.CreateNode("div", text);//循环嵌套:3 - 获取子节点,以便进行循环 XmlNode hostNode = View.Get("hostList", xmlNode);if (hostNode != null)
{
//循环嵌套:4 - 子节点,循环绑定数据。 View.SetForeach(dt, hostNode, hostNode.InnerXml, null);//循环嵌套:5 - 返回整个节点的内容。 returnxmlNode.InnerXml;
}
}
}
returntext;
}
#endregion}

最后,运行应用程序并查看页面的呈现效果。

您将看到一个包含一级标题和多个二级标题列表被成功显示在页面上。

总结

通过本篇教程,我们学习了如何在 Taurus.MVC WebMVC 中实现数据列表的绑定,使用 List<Model> 来展示多个数据项。

我们更新了 Model 类、控制器和视图,成功实现了一个简单的数据列表绑定示例。

本系列的目录大纲为:

Taurus.MVC WebMVC 入门开发教程1:框架下载环境配置与运行

Taurus.MVC WebMVC 入门开发教程2:一个简单的页面呈现

Taurus.MVC WebMVC 入门开发教程3:数据绑定Model

Taurus.MVC WebMVC 入门开发教程4:数据列表绑定List<Model>

Taurus.MVC WebMVC 入门开发教程5:表单提交与数据验证

Taurus.MVC WebMVC 入门开发教程6:路由配置与路由映射

Taurus.MVC WebMVC 入门开发教程7:部分视图和页面片段