在这篇文章中,我将建立一个个人博客,其中包含以Markdown方式撰写的文章。
我们将使用Spring Boot来开发项目以及其他一些工具和库。我们会将项目推送到GitHub存储库,并使用Heroku的自动部署功能将帖子发布到我们的博客中。
另外,我们将使用RemoteMySQL服务来托管我们的远程MySQL数据库。为了与数据库通信,我们将需要用于Java和Spring Data JPA的MySQL连接器。
为了呈现我们的视图,我们将使用Thymeleaf。
在整个教程中,我将使用IntelliJ IDEA社区版作为IDE。可能到处都有一些IntelliJ特定的快捷方式和热键,但是您可以继续使用所选的IDE。
现在开始吧!
构建项目
通过Spring Initializr 方式创建一个SpringBoot工程,并配置我们的项目。
选择“ Maven项目”选项,并选择Java作为语言。
选择非快照版本的Spring Boot(我选择了2.4.5)。
然后,填写您的项目元数据-包括组,工件,名称,描述和包名称。
我们将使用Jar包装,因此请选择Jar。然后,选择11作为Java版本。
对于依赖性,我们将需要以下内容:
- Spring Data JPA
- MySQL Driver
- Thymeleaf
- Spring Web
- Lombok
最后,通过单击生成按钮来生成项目结构。
提取生成的.zip文件,就可以开始了!
打开项目
提取.zip文件后,就可以将项目导入到IDE中。
使用IntelliJ IDEA,可以通过将IntelliJ指向项目的根文件夹(该文件夹包含pom.xml文件作为直接子文件夹)来打开项目。
打开项目后,查看一下Spring Intializr生成的目录和文件结构。
pom.xml文件对于Maven项目至关重要。
它充满了我们从Spring Initialzr的小型配置会话中选择的依赖项。
由于Spring Boot是一个自以为是的框架,因此在其启动程序依赖项中包含Spring Boot本身已管理的其他依赖项的集合。
定义实体
创建两个实体:posts 和 authors
authors实体中包含:email,name,url
posts实体中包含:content,date_time,synopsis,title,author_id
定义POJO
实体的Java对象(POJO)定义如下所述。
package np.com.roshanadhikary.mdblog.entities;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
import np.com.roshanadhikary.mdblog.util.LocalDateTimeConverter;
@Data
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
@Column
private long id;
@Column
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Column(length = 150)
private String synopsis;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
@Column
@Convert(converter = LocalDateTimeConverter.class)
private LocalDateTime dateTime;
}
package np.com.roshanadhikary.mdblog.entities;
import lombok.Data;
import javax.persistence.*;
import java.util.*;
@Data
@Entity
@Table(name = "authors")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private long id;
@Column
private String name;
@Column
private String email;
@Column
private String url;
@Column
@OneToMany(mappedBy = "author")
private List<Post> posts;
}
我们使用JPA批注指定posts 和 authors之间的一对多关系。
此外,要将Java的LocalDateTime类型的属性映射到MySQL的datetime类型的列,我们需要在两种类型之间进行转换(从LocalDateTime到TimeStamp,反之亦然)。
为此,我们定义了一个新的LocalDateTimeConverter类,如下所示。
package np.com.roshanadhikary.mdblog.util;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.sql.Timestamp;
import java.time.LocalDateTime;
@Converter(autoApply = true)
public class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, Timestamp> {
@Override
public Timestamp convertToDatabaseColumn(LocalDateTime localDateTime) {
return localDateTime == null ? null : Timestamp.valueOf(localDateTime);
}
@Override
public LocalDateTime convertToEntityAttribute(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
定义数据库
Spring Data提供了几种 Repository抽象,旨在减少用于数据访问层的人工生成的代码量。我们的博客需要的两个这样的抽象是 CrudRepository 和 PagingAndSortingRepository。
CrudRepository接口提供了用于对实体执行CRUD操作的各种方法。另一方面,PagingAndSortingRepository是CrudRepository的扩展,它提供了使用分页和排序抽象来检索实体的其他方法。
我们实体的存储库定义如下。
@Controller
@RequestMapping("/posts")
public class PostController {
// ... instance variable and constructor
// ... getPaginatedPosts method
@GetMapping("/{id}")
public String getPostById(@PathVariable long id, Model model) {
Optional<Post> postOptional = postRepository.findById(id);
if (postOptional.isPresent()) {
model.addAttribute("post", postOptional.get());
} else {
model.addAttribute("error", "no-post");
}
return "post";
}
}
package np.com.roshanadhikary.mdblog.repositories;
import np.com.roshanadhikary.mdblog.entities.Author;
import org.springframework.data.repository.CrudRepository;
public interface AuthorRepository extends CrudRepository<Author, Long> {
}
由于我们不需要为Author实体实现分页或排序,因此可以为AuthorRepository扩展CrudRepository。另一方面,由于我们将使用分页和排序抽象来检索帖子,因此我们需要为PostRepository扩展PagingAndSortingRepository。
请注意,CrudRepository和PagingAndSortingRepository如何将要管理的域类和域类ID的类型作为类型参数。
要注意的另一件事是,我们不需要使用@Repository或@Component注释存储库接口。这是因为CrudRepository和PagingAndSorting接口使用@NoRepositoryBean进行了注释,从而使它们成为中间接口。Spring容器不会拾取中间接口,因此不会将其实例化为bean。
但是,未使用@NoRepositoryBean注释的此类接口的任何派生类(在我们的示例中为AuthorRepository和PostRepository)都将由Spring容器自动实例化。
定义控制器
现在开始为博客定义控制器。这些将处理对我们的Web应用程序的请求,并负责将适当的视图传递给客户端。
我们需要定义两个类-RootController和PostController-每个类将处理各自的请求。
RootController
RootController将被映射为处理 GET / 请求。
package np.com.roshanadhikary.mdblog.controllers;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/")
public class RootController {
@GetMapping("")
public String index() {
return "redirect:/posts";
}
}
如我们所见,所有对/路由的GET请求都将重定向到/ posts路由。
PostController
PostController将被映射为处理 GET / posts / * 请求。
package np.com.roshanadhikary.mdblog.controllers;
import np.com.roshanadhikary.mdblog.repositories.PostRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/posts")
public class PostController {
private final PostRepository postRepository;
private final int PAGINATIONSIZE = 2;
@Autowired
public PostController(PostRepository postRepository) {
this.postRepository = postRepository;
}
}
此时,该类中没有定义处理程序方法。我们定义了一个名为PAGINATIONSIZE的实例变量,该变量指示分页的大小-即一次渲染的帖子数。
然后,我们注入PostRepository的实现。
我们将定义两个其他方法来处理对GET / posts和GET / posts / {id}的请求。
@Controller
@RequestMapping("/posts")
public class PostController {
// ... instance variable and constructor
@GetMapping("")
public String getPaginatedPosts(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value="size", defaultValue = "" + PAGINATIONSIZE) int size,
Model model) {
Pageable pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
Page<Post> postsPage = postRepository.findAll(pageRequest);
List<Post> posts = postsPage.toList();
long postCount = postRepository.count();
int numOfPages = (int) Math.ceil((postCount * 1.0) / PAGINATIONSIZE);
model.addAttribute("posts", posts);
model.addAttribute("postCount", postCount);
model.addAttribute("pageRequested", page);
model.addAttribute("paginationSize", PAGINATIONSIZE);
model.addAttribute("numOfPages", numOfPages);
return "posts";
}
}
该getPaginatedPosts方法有三个参数-请求的网页,每个网页的大小,和模型添加属性。
我们使用三个参数获取PageRequest的实例-页面,大小和发布顺序。
帖子的顺序应按其ID的降序排列,因为ID越大,该帖子的撰写时间越近。这是因为当我们检索帖子时,我们需要以递减的顺序对其进行排序。
然后,由于PostRepository扩展了PagingAndSortingRepository,因此它的findAll 方法可以采用上述的PageRequest实例,而后者又返回一个Pages页面 。可以将Page类型的此实例转换为List类型,而这正是我们所做的。
然后,使用可用的帖子总数,计算要提供给客户的页面总数。
最后,我们将这些值添加为Model参数的属性。
接下来,我们定义一个getPostById方法。
@Controller
@RequestMapping("/posts")
public class PostController {
// ... instance variable and constructor
// ... getPaginatedPosts method
@GetMapping("/{id}")
public String getPostById(@PathVariable long id, Model model) {
Optional<Post> postOptional = postRepository.findById(id);
if (postOptional.isPresent()) {
model.addAttribute("post", postOptional.get());
} else {
model.addAttribute("error", "no-post");
}
return "post";
}
}
getPostById方法采用两个参数-要检索的帖子的ID和要向其添加属性的模型。
使用PostRepository的findById方法,我们检索Post类型的Optional实例。 当我们减少碰到Null Pointer Exception的机会时,这提供了null安全感。
仅当Optional实例包含非空值(即,存在该ID的帖子)时,我们才将该帖子添加为模型属性。
定义应用程序属性
在application.properties文件中,我们指定数据库URL,用户名,密码和数据库初始化方法。
<dependencies>
<!-- other dependencies -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.17.1</version>
</dependency>
</dependencies>
前三个属性名称很容易解释,其值也是如此。
最后一个属性spring.jpa.hibernate.ddl-auto指定数据库模式生成方法。使用create-drop的值,我们指示Hibernate删除数据库架构,然后使用实体模型作为参考重新创建数据库架构。
现在,当我们运行@SpringBootApplication类(在我的情况下为MdBlogApplication)时,应该使用在数据库URL中指定的名称来创建数据库。
之后,我们开始阅读和解析博客文章的Markdown文件。首先,让我们添加 CommonMark 作为我们的依赖项之一。
将CommonMark添加到POM
<dependencies>
<!-- other dependencies -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.17.1</version>
</dependency>
</dependencies>
CommonMark将帮助我们从Markdown文件中解析Markdown内容,并从该内容中呈现HTML博客文章。
添加CommonMark依赖项后,IntelliJ IDEA将显示一个小图标,使我们可以加载Maven更改。这样,相关文件将被下载并集成到我们的类路径中。
现在我们准备解析一些Markdown!
Markdown文件的约定
从这一点开始,我们将假定我们的博客文章将作为Markdown文件存储在resources / posts /目录中。
每个Markdown文件将使用以下格式命名:1_Hello_World!.md
让我们解构文件名:
1:这是帖子的ID。它应该是唯一的,因为我们的实体Post 具有唯一的,自动生成的ID字段。
_:我们将使用下划线( _)作为分隔符,以将ID与帖子标题分开,并分隔标题中的单词。
Hello_World!: 我们博客文章的标题。
.md: Markdown文件的扩展名。
一旦我们开始从标记文件中读取行,使用这些约定的原因就显而易见了。
从Markdown文件读取行
我们需要编写一个实用程序类,其方法用于从Markdown文件中读取各行,从文件名中检索ID,并从文件名中检索标题。
现在,让我们实现读取单个行的方法。
package np.com.roshanadhikary.mdblog.util;
import org.springframework.core.io.ClassPathResource;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.List;
import java.util.stream.Collectors;
public class MdFileReader {
public static List<String> readLinesFromMdFile(String filename) {
try {
InputStream iStream = new ClassPathResource("/posts/" + filename)
.getInputStream();
BufferedReader bReader = new BufferedReader(new InputStreamReader(iStream));
return bReader.lines()
.collect(Collectors.toList());
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
public static String getTitleFromFileName(String filename) {
}
public static long getIdFromFileName(String filename) {
}
}
该readLinesFromMdFile方法接受一个文件名作为参数,并创建一个的InputStream从使用ClassPathResource下内部的名称可用资源/职位/目录。
我们使用InputStream创建一个BufferedReader实例,然后将文件中的各个行收集到一个List实例中,该实例从该方法返回。
现在开始从文件名检索ID和标题部分。
package np.com.roshanadhikary.mdblog.util;
import java.util.Arrays;
public class MdFileReader {
// ... readLinesFromMdFile method
public static String getTitleFromFileName(String filename) {
String fileNameBeforeExtension = filename.split(".md")[0];
String[] tokens = fileNameBeforeExtension.split("_");
String[] titleTokens = Arrays.copyOfRange(tokens, 1, tokens.length);
return String.join(" ", titleTokens);
}
public static long getIdFromFileName(String filename) {
String fileNameBeforeExtension = filename.split(".md")[0];
return Long.parseLong(fileNameBeforeExtension.split("_")[0]);
}
}
在getTitleFromFileName方法中,我们将扩展名(.md)与文件名的其余部分分开,并分割除ID部分之外的其余字符串。
同样,在getIdFromFileName方法中,我们将扩展名与其余文件名分开。然后,我们将ID部分解析为长值。
现在,我们终于可以从 Markdown行列表中呈现HTML内容 。
渲染HTML
我们需要使用一种方法来编写另一个实用程序类,该方法可以解析传递 的Markdown行的List并返回 呈现的HTML内容的String 。
package np.com.roshanadhikary.mdblog.util;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import java.util.List;
public class MdToHtmlRenderer {
/**
* Parse List of Markdown lines passed as argument, and render
* corresponding HTML
*/
public static String render(List<String> markdownLines) {
Parser parser = Parser.builder().build();
HtmlRenderer renderer = HtmlRenderer.builder().build();
StringBuilder renderedSB = new StringBuilder();
for (String markdownLine : markdownLines) {
Node document = parser.parse(markdownLine);
renderedSB.append(renderer.render(document));
}
return new String(renderedSB);
}
}
在render 方法中,我们使用诸如Parser之类的CommonMark类型 来解析Markdown内容,并使用 HtmlRenderer 来将解析后的Markdown内容呈现为HTML。
最后,我们返回一个 表示HTML博客文章的String。
小结
第一部分的代码我们到这里就结束了,后续将在下一部分更新