SpringBoot3+SpringSecurity+Thymeleaf的示例
前言
现在流行的都是复杂的架构,前后端分离,微服务,容器化,这些在工作中固然“美好”,听起来高大上,既可以骗经费,又可以解决“实际”问题,在无聊的面试中又可以充当主要角色。
但是在很多个人的简易项目中,单体应用是不可或缺的,没有那么多时间搞运维,并发量不大和业务也不复杂的情况下,我们可以放心的选择单体应用,一切都是简单而透明的。
本文以 SpringBoot3 + SpringSecurity + Thymeleaf 的组合,来编写一个简单的分页查询页面作为示例。
项目编写完后,部署到服务器的过程也会讲解,包括配置 Nginx 和 Systemd。
本文的示例风格,参考了 Flask 官方文档的博客应用示例:教程 — Flask中文文档(3.0.x)
语言、框架和数据库
语言:
JDK21
框架:
Spring Boot3
Spring Security
Thymeleaf
Spring Data JPA
数据库:
PostgreSQL
需求
围绕数据库中的一张表,draft(草稿),来编写一个简单的应用,包括以下功能:
- 为了安全性,有个登陆和注册页面。
- 围绕 draft 表的增删改查。
- 查询包含了一些字段,分页展示查询得到的数据。
项目初始化
pom 选择的依赖如下:
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
draft 表和数据库连接
本地 postgre 新建一个数据库,然后新建 draft 表:
-- ----------------------------
-- Table structure for draft
-- ----------------------------
DROP TABLE IF EXISTS "public"."draft";
CREATE TABLE "public"."draft" (
"id" uuid NOT NULL,
"title" varchar(255) COLLATE "pg_catalog"."default",
"content" text COLLATE "pg_catalog"."default",
"create_datetime" timestamp(6),
"update_datetime" timestamp(6)
)
;
-- ----------------------------
-- Primary Key structure for table draft
-- ----------------------------
ALTER TABLE "public"."draft" ADD CONSTRAINT "draft_pkey" PRIMARY KEY ("id");
字段很简单,title 表示草稿的标题,content 表示草稿的内容,两个时间戳,一个是创建时间,一个是更新时间。
Spring Boot 的配置文件采用 profile 形式,application.properties 内容如下:
# 服务名称
spring.application.name=toolbox
# 关闭 open-in-view
spring.jpa.open-in-view=false
# 使用的 profile
spring.profiles.active=dev
application-dev.properties 内容如下:
# postgresql
spring.datasource.url=jdbc:postgresql://localhost:5432/djhx
spring.datasource.username=postgres
spring.datasource.password=123456
spring.datasource.driver-class-name=org.postgresql.Driver
配置好后,启动程序,没有报错,说明数据库连接成功,并且 Spring Security 会默认输出一个 UUID 的密码:
到这一步,主要是确认本地的数据库能够正常连接。
日志配置
logback 的日志记录也很重要,贴上我常用的配置文件,logback-spring.xml:
<?xml version="1.0" encoding="utf-8" ?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- 开发环境-日志级别 -->
<!-- TRACE < DEBUG < INFO < WARN < ERROR -->
<property
name="DEV_LOG_LEVEL"
value="INFO"
/>
<!-- 测试环境-日志级别 -->
<property
name="TEST_LOG_LEVEL"
value="INFO"
/>
<!-- 生产环境-日志级别 -->
<property
name="PROD_LOG_LEVEL"
value="INFO"
/>
<!-- 日志存储目录路径 -->
<property
name="LOGS_DIR"
value="./logs"
/>
<!-- 日志文件名称格式 -->
<property
name="LOG_FILE_NAME_PATTERN"
value="log_%d{yyyy-MM-dd}.log"
/>
<!-- 日志保留最大数量 -->
<property
name="SAVE_LOG_MAX_NUM"
value="3"
/>
<!-- 控制台日志输出格式 -->
<property
name="CONSOLE_LOG_PATTERN"
value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] - [%highlight(%-5level)] - [%-15.30thread] - [%clr(%-20.30logger{30}){cyan}] : %msg%n"
/>
<!-- 日志文件存储输出格式 -->
<property
name="FILE_LOG_PATTERN"
value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] - [%-5level] - [%-15thread] - [%-20logger] : %msg%n"
/>
<!-- 日志色彩 -->
<conversionRule
conversionWord="clr"
class="org.springframework.boot.logging.logback.ColorConverter"
/>
<!-- appender console -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- appender debug -->
<appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<prudent>true</prudent>
<encoder><pattern>${FILE_LOG_PATTERN}</pattern></encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOGS_DIR}/debug/${LOG_FILE_NAME_PATTERN}</fileNamePattern>
<maxHistory>${SAVE_LOG_MAX_NUM}</maxHistory>
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- appender info -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<prudent>true</prudent>
<encoder><pattern>${FILE_LOG_PATTERN}</pattern></encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOGS_DIR}/info/${LOG_FILE_NAME_PATTERN}</fileNamePattern>
<maxHistory>${SAVE_LOG_MAX_NUM}</maxHistory>
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- appender warn -->
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<prudent>true</prudent>
<encoder><pattern>${FILE_LOG_PATTERN}</pattern></encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOGS_DIR}/warn/${LOG_FILE_NAME_PATTERN}</fileNamePattern>
<maxHistory>${SAVE_LOG_MAX_NUM}</maxHistory>
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- appender error -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<prudent>true</prudent>
<encoder><pattern>${FILE_LOG_PATTERN}</pattern></encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOGS_DIR}/error/${LOG_FILE_NAME_PATTERN}</fileNamePattern>
<maxHistory>${SAVE_LOG_MAX_NUM}</maxHistory>
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- appender all -->
<!-- 记录 level 为 info 及以上的所有日志到一个文件中 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<prudent>true</prudent>
<encoder><pattern>${FILE_LOG_PATTERN}</pattern></encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOGS_DIR}/all/${LOG_FILE_NAME_PATTERN}</fileNamePattern>
<maxHistory>${SAVE_LOG_MAX_NUM}</maxHistory>
</rollingPolicy>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>DENY</onMatch>
<onMismatch>ACCEPT</onMismatch>
</filter>
</appender>
<!-- 根记录器-根据 spring profile 的配置来选择 -->
<!-- 没有配置 profile 则默认是 default -->
<springProfile name="default">
<root level="${DEV_LOG_LEVEL}">
<appender-ref ref="CONSOLE" />
<appender-ref ref="DEBUG_FILE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="WARN_FILE" />
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="ALL_FILE" />
</root>
</springProfile>
<!-- profile=dev -->
<springProfile name="dev">
<root level="${DEV_LOG_LEVEL}">
<appender-ref ref="CONSOLE" />
<appender-ref ref="DEBUG_FILE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="WARN_FILE" />
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="ALL_FILE" />
</root>
</springProfile>
<!-- profile=test -->
<springProfile name="test">
<root level="${TEST_LOG_LEVEL}">
<appender-ref ref="CONSOLE" />
<appender-ref ref="DEBUG_FILE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="WARN_FILE" />
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="ALL_FILE" />
</root>
</springProfile>
<!-- profile=prod -->
<springProfile name="prod">
<root level="${PROD_LOG_LEVEL}">
<appender-ref ref="CONSOLE" />
<appender-ref ref="DEBUG_FILE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="WARN_FILE" />
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="ALL_FILE" />
</root>
</springProfile>
</configuration>
该文件放在 resources
目录下即可。
业务代码
entity 的基类
BaseEntity 包含了 id 和 create_datetime,update_datetime 三个字段,其他 entity 继承该类,避免重复性的编写这三个字段:
@Data
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(columnDefinition = "UUID", updatable = false, nullable = false)
private UUID id;
@Column(name = "create_datetime", updatable = false, nullable = false)
@CreationTimestamp
private LocalDateTime createDatetime;
@Column(name = "update_datetime")
@UpdateTimestamp
private LocalDateTime updateDatetime;
}
Draft 类:
@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@Table(name = "draft")
public class Draft extends BaseEntity {
@Column(name = "title")
private String title;
@Column(name = "content")
private String content;
}
DraftRepository 类:
@Repository
public interface DraftRepository extends JpaRepository<Draft, UUID>, JpaSpecificationExecutor<Draft> {
}