Spring Boot 3.x + Thymeleaf 实战:手把手教你从零搭建一个带导航栏的博客后台
Spring Boot 3.x与Thymeleaf实战:构建现代化博客后台管理系统
在当今快速发展的Web开发领域,选择合适的技术栈对于构建高效、可维护的应用程序至关重要。Spring Boot作为Java生态中最受欢迎的框架之一,与Thymeleaf模板引擎的结合,为开发者提供了一套完整的服务器端渲染解决方案。本文将带领您从零开始,使用Spring Boot 3.x最新稳定版和Thymeleaf,构建一个功能完善的博客后台管理系统,重点实现导航栏、页面布局和数据展示等核心功能。
1. 项目初始化与环境配置
开始之前,我们需要准备开发环境并初始化项目结构。推荐使用IntelliJ IDEA或VS Code作为开发工具,它们对Spring Boot和Thymeleaf都有良好的支持。
首先,通过Spring Initializr创建项目基础结构:
# 使用curl命令快速生成项目骨架
curl https://start.spring.io/starter.zip \
-d dependencies=web,thymeleaf \
-d type=gradle-project \
-d language=java \
-d bootVersion=3.2.0 \
-d groupId=com.example \
-d artifactId=blog-admin \
-o blog-admin.zip
解压后,您会得到以下基础项目结构:
blog-admin/
├── src/
│ ├── main/
│ │ ├── java/com/example/blogadmin/
│ │ │ └── BlogAdminApplication.java
│ │ ├── resources/
│ │ │ ├── static/
│ │ │ ├── templates/
│ │ │ └── application.properties
│ └── test/
└── build.gradle
为了提升开发体验,我们需要进行一些关键配置。在 application.properties 中添加以下内容:
# Thymeleaf配置
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8
# 开发工具配置
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=true
提示:关闭Thymeleaf缓存(
spring.thymeleaf.cache=false)可以让模板修改立即生效,非常适合开发环境。但在生产环境中,应该将其设置为true以提高性能。
2. 构建基础页面布局
良好的页面布局是后台管理系统的基石。我们将使用Thymeleaf的布局功能来创建可复用的页面结构。
首先,在 resources/templates 目录下创建 fragments/layout.html 文件,作为基础布局模板:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="base-head(title)">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${title}">Blog Admin</title>
<link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<link th:href="@{/css/admin.css}" rel="stylesheet">
</head>
<body>
<!-- 导航栏片段 -->
<header th:fragment="navbar">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" th:href="@{/}">博客后台</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/posts}" th:classappend="${#httpServletRequest.requestURI == '/posts'} ? 'active'">文章管理</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/categories}" th:classappend="${#httpServletRequest.requestURI == '/categories'} ? 'active'">分类管理</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/comments}" th:classappend="${#httpServletRequest.requestURI == '/comments'} ? 'active'">评论管理</a>
</li>
</ul>
<div class="d-flex">
<span class="navbar-text me-3" th:text="'欢迎,' + ${#authentication.name}"></span>
<form th:action="@{/logout}" method="post">
<button class="btn btn-outline-light" type="submit">退出</button>
</form>
</div>
</div>
</div>
</nav>
</header>
<!-- 主要内容区域 -->
<main class="container mt-4">
<div th:replace="~{fragments/alerts :: alerts}"></div>
<div th:insert="~{${content}}"></div>
</main>
<!-- 页脚 -->
<footer class="mt-5 py-3 bg-light">
<div class="container text-center">
<span class="text-muted">© 2023 博客后台管理系统</span>
</div>
</footer>
<script th:src="@{/js/bootstrap.bundle.min.js}"></script>
<script th:src="@{/js/admin.js}"></script>
</body>
</html>
这个布局模板定义了以下几个关键部分:
- 基础头部(fragment="base-head") :包含页面标题、CSS引用等
- 导航栏(fragment="navbar") :使用Bootstrap 5构建响应式导航
- 主要内容区域 :动态插入各页面的具体内容
- 页脚 :显示版权信息
- 脚本引用 :包含所有必要的JavaScript文件
接下来,我们创建首页模板 index.html :
<!DOCTYPE html>
<html th:replace="~{fragments/layout :: html}">
<head>
<title>博客后台首页</title>
</head>
<body>
<div th:fragment="content">
<h1 class="mb-4">仪表盘</h1>
<div class="row">
<div class="col-md-4">
<div class="card text-white bg-primary mb-3">
<div class="card-body">
<h5 class="card-title" th:text="${postCount} + ' 篇文章'">0 篇文章</h5>
<p class="card-text">已发布<span th:text="${publishedCount}"></span>篇</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-success mb-3">
<div class="card-body">
<h5 class="card-title" th:text="${categoryCount} + ' 个分类'">0 个分类</h5>
<p class="card-text">文章分类管理</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-info mb-3">
<div class="card-body">
<h5 class="card-title" th:text="${commentCount} + ' 条评论'">0 条评论</h5>
<p class="card-text">待审核<span th:text="${pendingCommentCount}"></span>条</p>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
3. 实现文章管理功能
博客后台的核心功能之一是文章管理。我们将创建一个完整的CRUD界面,包括列表展示、创建、编辑和删除功能。
首先,定义文章实体类 Post.java :
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "标题不能为空")
@Size(max = 100, message = "标题长度不能超过100个字符")
private String title;
@NotBlank(message = "内容不能为空")
@Column(columnDefinition = "TEXT")
private String content;
@ManyToOne
@JoinColumn(name = "category_id")
private Category category;
private boolean published;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// 省略getter和setter方法
}
创建控制器 PostController.java 处理文章相关请求:
@Controller
@RequestMapping("/posts")
public class PostController {
private final PostService postService;
@GetMapping
public String listPosts(Model model,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
Page<Post> postPage = postService.findAll(page - 1, size);
model.addAttribute("posts", postPage.getContent());
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", postPage.getTotalPages());
return "posts/list";
}
@GetMapping("/create")
public String showCreateForm(Model model) {
model.addAttribute("post", new Post());
model.addAttribute("categories", categoryService.findAll());
return "posts/form";
}
@PostMapping
public String createPost(@Valid Post post, BindingResult result) {
if (result.hasErrors()) {
return "posts/form";
}
postService.save(post);
return "redirect:/posts";
}
@GetMapping("/edit/{id}")
public String showEditForm(@PathVariable Long id, Model model) {
Post post = postService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("文章不存在"));
model.addAttribute("post", post);
model.addAttribute("categories", categoryService.findAll());
return "posts/form";
}
@PostMapping("/update/{id}")
public String updatePost(@PathVariable Long id, @Valid Post post, BindingResult result) {
if (result.hasErrors()) {
return "posts/form";
}
post.setId(id);
postService.save(post);
return "redirect:/posts";
}
@GetMapping("/delete/{id}")
public String deletePost(@PathVariable Long id) {
postService.deleteById(id);
return "redirect:/posts";
}
}
创建文章列表模板 posts/list.html :
<!DOCTYPE html>
<html th:replace="~{fragments/layout :: html}">
<head>
<title>文章管理</title>
</head>
<body>
<div th:fragment="content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>文章列表</h1>
<a th:href="@{/posts/create}" class="btn btn-primary">新建文章</a>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>分类</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr th:each="post : ${posts}">
<td th:text="${post.id}"></td>
<td>
<a th:href="@{'/posts/' + ${post.id}}" th:text="${post.title}"></a>
</td>
<td th:text="${post.category?.name} ?: '未分类'"></td>
<td>
<span th:if="${post.published}" class="badge bg-success">已发布</span>
<span th:unless="${post.published}" class="badge bg-secondary">草稿</span>
</td>
<td th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd HH:mm')}"></td>
<td>
<div class="btn-group btn-group-sm">
<a th:href="@{'/posts/edit/' + ${post.id}}" class="btn btn-outline-primary">编辑</a>
<form th:action="@{'/posts/delete/' + ${post.id}}" method="post">
<button type="submit" class="btn btn-outline-danger"
onclick="return confirm('确定要删除这篇文章吗?')">删除</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
<nav th:if="${totalPages > 1}">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${currentPage == 1} ? 'disabled'">
<a class="page-link" th:href="@{/posts(page=${currentPage - 1})}">上一页</a>
</li>
<li th:each="i : ${#numbers.sequence(1, totalPages)}"
class="page-item" th:classappend="${i == currentPage} ? 'active'">
<a class="page-link" th:href="@{/posts(page=${i})}" th:text="${i}"></a>
</li>
<li class="page-item" th:classappend="${currentPage == totalPages} ? 'disabled'">
<a class="page-link" th:href="@{/posts(page=${currentPage + 1})}">下一页</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</body>
</html>
创建文章表单模板 posts/form.html ,用于创建和编辑文章:
<!DOCTYPE html>
<html th:replace="~{fragments/layout :: html}">
<head>
<title th:text="${post.id != null} ? '编辑文章' : '新建文章'"></title>
</head>
<body>
<div th:fragment="content">
<h1 th:text="${post.id != null} ? '编辑文章' : '新建文章'" class="mb-4"></h1>
<form th:action="${post.id != null} ? @{'/posts/update/' + ${post.id}} : @{/posts}"
method="post" th:object="${post}">
<div class="card">
<div class="card-body">
<div class="mb-3">
<label for="title" class="form-label">标题</label>
<input type="text" class="form-control" id="title" th:field="*{title}"
th:classappend="${#fields.hasErrors('title')} ? 'is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('title')}"
th:errors="*{title}"></div>
</div>
<div class="mb-3">
<label for="category" class="form-label">分类</label>
<select class="form-select" id="category" th:field="*{category}">
<option value="">-- 请选择分类 --</option>
<option th:each="category : ${categories}"
th:value="${category.id}"
th:text="${category.name}"
th:selected="${post.category != null && post.category.id == category.id}">
</option>
</select>
</div>
<div class="mb-3">
<label for="content" class="form-label">内容</label>
<textarea class="form-control" id="content" rows="10" th:field="*{content}"
th:classappend="${#fields.hasErrors('content')} ? 'is-invalid'"></textarea>
<div class="invalid-feedback" th:if="${#fields.hasErrors('content')}"
th:errors="*{content}"></div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="published" th:field="*{published}">
<label class="form-check-label" for="published">发布</label>
</div>
<div class="d-flex justify-content-end">
<a th:href="@{/posts}" class="btn btn-secondary me-2">取消</a>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</div>
</div>
</form>
</div>
</body>
</html>
4. 高级功能实现与优化
4.1 富文本编辑器集成
为了提升文章编辑体验,我们可以集成一个富文本编辑器。这里以CKEditor为例:
首先,在 pom.xml 中添加CKEditor依赖:
<dependency>
<groupId>com.vaadin.external.google</groupId>
<artifactId>android-json</artifactId>
<version>0.0.20131108.vaadin1</version>
<scope>compile</scope>
</dependency>
然后,修改 posts/form.html 中的内容文本区域:
<textarea class="form-control" id="content" rows="10" th:field="*{content}"></textarea>
<script th:inline="javascript">
ClassicEditor
.create(document.querySelector('#content'), {
toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote'],
heading: {
options: [
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }
]
}
})
.catch(error => {
console.error(error);
});
</script>
4.2 文件上传功能
实现图片上传功能可以丰富文章内容。首先创建文件上传控制器:
@Controller
@RequestMapping("/upload")
public class FileUploadController {
@Value("${upload.dir}")
private String uploadDir;
@PostMapping("/image")
@ResponseBody
public Map<String, String> uploadImage(@RequestParam("upload") MultipartFile file) {
if (file.isEmpty()) {
throw new IllegalArgumentException("上传文件不能为空");
}
try {
String filename = UUID.randomUUID() + "." +
FilenameUtils.getExtension(file.getOriginalFilename());
Path path = Paths.get(uploadDir, filename);
Files.createDirectories(path.getParent());
Files.copy(file.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
return Map.of(
"uploaded", "true",
"url", "/uploads/" + filename
);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
}
然后配置CKEditor的文件上传:
ClassicEditor
.create(document.querySelector('#content'), {
// ... 其他配置
ckfinder: {
uploadUrl: '/upload/image'
}
})
// ...
4.3 国际化支持
为系统添加多语言支持可以提升用户体验。首先创建消息资源文件:
messages.properties :
navbar.brand=博客后台
navbar.posts=文章管理
navbar.categories=分类管理
navbar.comments=评论管理
messages_zh_CN.properties :
navbar.brand=博客后台
navbar.posts=文章管理
navbar.categories=分类管理
navbar.comments=评论管理
然后在Spring配置中添加国际化支持:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver();
slr.setDefaultLocale(Locale.US);
return slr;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
lci.setParamName("lang");
return lci;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
@Bean
public ResourceBundleMessageSource messageSource() {
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
source.setBasenames("messages");
source.setDefaultEncoding("UTF-8");
return source;
}
}
最后在模板中使用国际化消息:
<a class="navbar-brand" th:href="@{/}" th:text="#{navbar.brand}"></a>
<a class="nav-link" th:href="@{/posts}" th:text="#{navbar.posts}"></a>
4.4 安全性增强
使用Spring Security保护后台管理系统:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**", "/js/**", "/uploads/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
}
创建登录页面 login.html :
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
<link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">后台登录</h2>
<form th:action="@{/login}" method="post">
<div th:if="${param.error}" class="alert alert-danger">
用户名或密码错误
</div>
<div th:if="${param.logout}" class="alert alert-success">
您已成功退出
</div>
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">登录</button>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
5. 性能优化与部署
5.1 缓存策略
在生产环境中,合理使用缓存可以显著提升系统性能。我们可以为文章列表添加缓存:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("posts");
}
}
@Service
public class PostServiceImpl implements PostService {
@Cacheable("posts")
@Override
public Page<Post> findAll(int page, int size) {
// 数据库查询逻辑
}
@CacheEvict(value = "posts", allEntries = true)
@Override
public Post save(Post post) {
// 保存逻辑
}
@CacheEvict(value = "posts", allEntries = true)
@Override
public void deleteById(Long id) {
// 删除逻辑
}
}
5.2 静态资源优化
对于生产环境,我们应该启用资源压缩和缓存:
# 启用资源压缩
server.compression.enabled=true
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
server.compression.min-response-size=1024
# 静态资源缓存
spring.web.resources.cache.cachecontrol.max-age=365d
spring.web.resources.cache.cachecontrol.immutable=true
5.3 部署配置
准备生产环境配置文件 application-prod.properties :
# 生产环境配置
spring.profiles.active=prod
spring.thymeleaf.cache=true
spring.datasource.url=jdbc:mysql://localhost:3306/blog_admin?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
# 文件上传配置
upload.dir=/var/uploads/blog
# 安全配置
spring.security.user.name=admin
spring.security.user.password=securepassword
使用Gradle构建生产包:
./gradlew clean build -Pprod
构建完成后,可以使用以下命令运行应用:
java -jar build/libs/blog-admin-0.0.1-SNAPSHOT.jar
或者使用Docker容器化部署:
FROM openjdk:17-jdk-slim
COPY build/libs/blog-admin-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
构建并运行Docker容器:
docker build -t blog-admin .
docker run -d -p 8080:8080 -v /var/uploads/blog:/var/uploads/blog blog-admin
更多推荐



所有评论(0)