SpringBoot建立基于Markdown的博客—第1部分

在这篇文章中,我将建立一个个人博客,其中包含以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文件,就可以开始了!
SpringBoot建立基于Markdown的博客—第1部分

打开项目

提取.zip文件后,就可以将项目导入到IDE中。

使用IntelliJ IDEA,可以通过将IntelliJ指向项目的根文件夹(该文件夹包含pom.xml文件作为直接子文件夹)来打开项目。
SpringBoot建立基于Markdown的博客—第1部分
打开项目后,查看一下Spring Intializr生成的目录和文件结构。
SpringBoot建立基于Markdown的博客—第1部分
pom.xml文件对于Maven项目至关重要。

它充满了我们从Spring Initialzr的小型配置会话中选择的依赖项。

由于Spring Boot是一个自以为是的框架,因此在其启动程序依赖项中包含Spring Boot本身已管理的其他依赖项的集合。

定义实体

创建两个实体:posts 和 authors
authors实体中包含:email,name,url
posts实体中包含:content,date_time,synopsis,title,author_id
SpringBoot建立基于Markdown的博客—第1部分

定义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博客文章。
SpringBoot建立基于Markdown的博客—第1部分
添加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。

小结

第一部分的代码我们到这里就结束了,后续将在下一部分更新

上一篇:使用 Kotlin 协程 + Fuel 调用 REST API


下一篇:AutoCAD 凸度(bulge)的概念及使用WPF函数画图