Fauna 和 Spring 构建 Web 应用程序
1. 简介
在本文中,我们将使用 Spring 和 Java 17构建由**Fauna 数据库服务 **支持的博客服务的后端。
2. 项目设置
在开始构建服务之前,我们需要执行一些初始设置步骤——具体来说,我们需要创建一个 Fauna 数据库和一个空白 Spring 应用程序。
2.1. 创建数据库
**在开始之前,我们需要一个 Fauna 数据库来使用。**如果我们还没有,我们需要使用 Fauna 创建一个新帐户 。
完成后,我们可以创建一个新的数据库。给它一个名称和一个区域,并选择不包含演示数据,因为我们要构建自己的模式:
接下来,**我们需要创建一个安全密钥来从我们的应用程序中访问它。**我们可以从数据库中的安全选项卡中执行此操作:
在这里,我们需要选择“服务器”的“角色”,并且可以选择为键命名。这意味着密钥可以访问这个数据库,但只能访问这个数据库。或者,我们有一个“管理员”选项,可用于访问我们帐户中的任何数据库:
完成后,我们需要写下我们的秘密。这是访问服务所必需的,但出于安全原因,离开此页面后无法再次获取。
2.2. 创建一个 Spring 应用程序
**一旦我们有了我们的数据库,我们就可以创建我们的应用程序。**由于这将是一个 Spring webapp,我们最好从Spring Initializr 引导它。
我们想选择使用最新版本的 Spring 和最新的 LTS 版本的 Java 创建 Maven 项目的选项——在撰写本文时,它们是 Spring 2.6.2 和 Java 17。我们还想选择 Spring Web 和 Spring安全性作为我们服务的依赖项:
一旦我们在这里完成,我们可以点击“生成”按钮下载我们的启动项目。
接下来,我们需要将 Fauna 驱动程序添加到我们的项目中。这是通过在生成的pom.xml文件中添加对它们的依赖来完成的:
<dependency>
<groupId>com.faunadb</groupId>
<artifactId>faunadb-java</artifactId>
<version>4.2.0</version>
<scope>compile</scope>
</dependency>
此时,我们应该能够执行mvn install并让构建成功下载我们需要的所有内容。
2.3. 配置 Fauna 客户端
一旦我们有一个 Spring webapp 可以使用,我们需要一个 Fauna 客户端来使用数据库。
首先,我们有一些配置要做。为此,我们将在application.properties文件中添加两个属性,为我们的数据库提供正确的值:
fauna.region=us
fauna.secret=<Secret>
然后,我们需要一个新的 Spring 配置类来构建 Fauna 客户端:
@Configuration
class FaunaConfiguration {
@Value("https://db.${fauna.region}.fauna.com/")
private String faunaUrl;
@Value("${fauna.secret}")
private String faunaSecret;
@Bean
FaunaClient getFaunaClient() throws MalformedURLException {
return FaunaClient.builder()
.withEndpoint(faunaUrl)
.withSecret(faunaSecret)
.build();
}
}
这使得FaunaClient的实例可用于 Spring 上下文以供其他 bean 使用。
3. 增加对用户的支持
**在向我们的 API 添加对帖子的支持之前,我们需要对将要创作这些帖子的用户提供支持。**为此,我们将使用 Spring Security 并将其连接到代表用户记录的 Fauna 集合。
3.1. 创建用户集合
**我们要做的第一件事是创建集合。**这是通过导航到我们数据库中的集合屏幕,使用“新集合”按钮并填写表格来完成的。在这种情况下,我们要使用默认设置创建一个“用户”集合:
接下来,我们将添加一条用户记录。为此,我们按下集合中的“新建文档”按钮并提供以下 JSON:
{
"username": "blogdemo",
"password": "Pa55word",
"name": "Blogdemo"
}
请注意,我们在这里以明文形式存储密码。请记住,这是一种糟糕的做法,只是为了方便本教程。
最后,我们需要一个索引。每当我们想通过引用以外的任何字段访问记录时,我们都需要创建一个索引来让我们这样做。在这里,我们想通过用户名访问记录。这是通过按“新索引”按钮并填写表格来完成的:
现在,我们将能够使用“users_by_username”索引编写 FQL 查询来查找我们的用户。例如:
Map(
Paginate(Match(Index("users_by_username"), "blogdemo")),
Lambda("user", Get(Var("user")))
)
以上将返回我们之前创建的记录。
3.2. 身份验证
现在我们在 Fauna 中有一组用户,我们可以配置 Spring Security 来对此进行身份验证。 为了实现这一点,我们首先需要一个UserDetailsService 来查找用户和 Fauna:
public class FaunaUserDetailsService implements UserDetailsService {
private final FaunaClient faunaClient;
// standard constructors
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
Value user = faunaClient.query(Map(
Paginate(Match(Index("users_by_username"), Value(username))),
Lambda(Value("user"), Get(Var("user")))))
.get();
Value userData = user.at("data").at(0).orNull();
if (userData == null) {
throw new UsernameNotFoundException("User not found");
}
return User.withDefaultPasswordEncoder()
.username(userData.at("data", "username").to(String.class).orNull())
.password(userData.at("data", "password").to(String.class).orNull())
.roles("USER")
.build();
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
}
}
}
接下来,我们需要一些 Spring 配置来设置它。这是连接上述UserDetailsService的标准 Spring Security 配置:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private FaunaClient faunaClient;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests()
.antMatchers("/**").permitAll()
.and().httpBasic();
}
@Bean
@Override
public UserDetailsService userDetailsService() {
return new FaunaUserDetailsService(faunaClient);
}
}
此时,我们可以在代码中添加标准的*@PreAuthorize*注解,并根据 Fauna 中的“用户”集合中是否存在身份验证详细信息来接受或拒绝请求。
4. 添加对列表帖子的支持
**如果不支持帖子的概念,我们的博客服务就不会出色。**这些是已经写好的并且可以被其他人阅读的实际博客文章。
4.1. 创建帖子集合
**和以前一样,我们首先需要一个集合来存储帖子。**它的创建方式相同,仅称为“帖子”而不是“用户”。我们将有四个字段:
- 标题 - 帖子的标题。
- 内容 - 帖子的内容。
- created – 发布帖子的时间戳。
- authorRef – 对帖子作者的“用户”记录的引用。
我们还需要两个索引。第一个是“posts_by_author”,它可以让我们搜索具有特定作者的“帖子”记录:
第二个索引将是“posts_sort_by_created_desc”。这将允许我们按创建日期对结果进行排序,以便首先返回最近创建的帖子。我们需要以不同的方式创建它,因为它依赖于 Web UI 中不可用的功能——指示索引以相反的顺序存储值。
为此,我们需要在 Fauna Shell 中执行一段 FQL:
CreateIndex({
name: "posts_sort_by_created_desc",
source: Collection("posts"),
terms: [ { field: ["ref"] } ],
values: [
{ field: ["data", "created"], reverse: true },
{ field: ["ref"] }
]
})
Web UI 所做的所有事情都可以通过这种方式同样完成,从而可以更好地控制所做的事情。
然后我们可以在 Fauna Shell 中创建一个帖子来获取一些起始数据:
Create(
Collection("posts"),
{
data: {
title: "My First Post",
contents: "This is my first post",
created: Now(),
authorRef: Select("ref", Get(Match(Index("users_by_username"), "blogdemo")))
}
}
)
在这里,我们需要确保“authorRef”的值是我们之前创建的“用户”记录中的正确值。我们通过查询“users_by_username”索引来通过查找我们的用户名来获取引用来做到这一点。
4.2. POST服务
现在我们已经支持 Fauna 中的帖子,我们可以在我们的应用程序中构建一个服务层来使用它。
首先,我们需要一些 Java 记录来表示我们正在获取的数据。这将由Author和Post记录类组成:
public record Author(String username, String name) {}
public record Post(String id, String title, String content, Author author, Instant created, Long version) {}
现在,我们可以启动我们的 Posts Service。这将是一个包装FaunaClient并使用它来访问数据存储的 Spring 组件:
@Component
public class PostsService {
@Autowired
private FaunaClient faunaClient;
}
4.3. 获取所有帖子
**在我们的PostsService中,我们现在可以实现一个获取所有帖子的方法。**在这一点上,我们不会担心正确的分页,而是只使用默认值——这意味着结果集中的前 64 个文档。
为此,我们将在PostsService类中添加以下方法:
List<Post> getAllPosts() throws Exception {
var postsResult = faunaClient.query(Map(
Paginate(
Join(
Documents(Collection("posts")),
Index("posts_sort_by_created_desc")
)
),
Lambda(
Arr(Value("extra"), Value("ref")),
Obj(
"post", Get(Var("ref")),
"author", Get(Select(Arr(Value("data"), Value("authorRef")), Get(Var("ref"))))
)
)
)).get();
var posts = postsResult.at("data").asCollectionOf(Value.class).get();
return posts.stream().map(this::parsePost).collect(Collectors.toList());
}
**这将执行一个查询以从“posts”集合中检索每个文档,并根据“posts_sort_by_created_desc”索引进行排序。**然后它应用 Lambda 来构建响应,每个条目包含两个文档——帖子本身和帖子的作者。
现在,我们需要能够将此响应转换回我们的Post对象:
private Post parsePost(Value entry) {
var author = entry.at("author");
var post = entry.at("post");
return new Post(
post.at("ref").to(Value.RefV.class).get().getId(),
post.at("data", "title").to(String.class).get(),
post.at("data", "contents").to(String.class).get(),
new Author(
author.at("data", "username").to(String.class).get(),
author.at("data", "name").to(String.class).get()
),
post.at("data", "created").to(Instant.class).get(),
post.at("ts").to(Long.class).get()
);
}
这从我们的查询中获取单个结果,提取其所有值,并构造我们更丰富的对象。
请注意,“ts”字段是记录上次更新时间的时间戳,但它不是 Fauna Timestamp类型。相反,它是一个Long表示自 UNIX 纪元以来的微秒数。在这种情况下,我们将其视为不透明的版本标识符,而不是将其解析为时间戳。
4.4. 获取单个作者的帖子
我们还希望检索由特定作者撰写的所有帖子,而不仅仅是曾经写过的每篇帖子。这是使用我们的“posts_by_author”索引而不是仅仅匹配每个文档的问题。
我们还将链接到“users_by_username”索引以通过用户名而不是用户记录的 ref 进行查询。
为此,我们将向PostsService类添加一个新方法:
List<Post> getAuthorPosts(String author) throws Exception {
var postsResult = faunaClient.query(Map(
Paginate(
Join(
Match(Index("posts_by_author"), Select(Value("ref"), Get(Match(Index("users_by_username"), Value(author))))),
Index("posts_sort_by_created_desc")
)
),
Lambda(
Arr(Value("extra"), Value("ref")),
Obj(
"post", Get(Var("ref")),
"author", Get(Select(Arr(Value("data"), Value("authorRef")), Get(Var("ref"))))
)
)
)).get();
var posts = postsResult.at("data").asCollectionOf(Value.class).get();
return posts.stream().map(this::parsePost).collect(Collectors.toList());
}
4.5. 控制器
**我们现在可以编写我们的帖子控制器,这将允许对我们的服务的 HTTP 请求来检索帖子。**这将侦听“/posts” URL,并将返回所有帖子或单个作者的帖子,具体取决于是否提供了“author”参数:
@RestController
@RequestMapping("/posts")
public class PostsController {
@Autowired
private PostsService postsService;
@GetMapping
public List<Post> listPosts(@RequestParam(value = "author", required = false) String author)
throws Exception {
return author == null
? postsService.getAllPosts()
: postsService.getAuthorPosts(author);
}
}
此时,我们可以启动我们的应用程序并向*/posts或/posts?author=blogdemo*发出请求并获得结果:
[
{
"author": {
"name": "Blogdemo",
"username": "blogdemo"
},
"content": "Introduction to FaunaDB with Spring",
"created": "2022-01-25T07:36:24.563534Z",
"id": "321742264960286786",
"title": "Introduction to FaunaDB with Spring",
"version": 1643096184600000
},
{
"author": {
"name": "Blogdemo",
"username": "blogdemo"
},
"content": "This is my second post",
"created": "2022-01-25T07:34:38.303614Z",
"id": "321742153548038210",
"title": "My Second Post",
"version": 1643096078350000
},
{
"author": {
"name": "Blogdemo",
"username": "blogdemo"
},
"content": "This is my first post",
"created": "2022-01-25T07:34:29.873590Z",
"id": "321742144715882562",
"title": "My First Post",
"version": 1643096069920000
}
]
5. 创建和更新帖子
到目前为止,我们有一个完全只读的服务,可以让我们获取最新的帖子。但是,为了提供帮助,我们也希望创建和更新帖子。
5.1. 创建新帖子
首先,我们将支持创建新帖子。为此,我们将向PostsService添加一个新方法:
public void createPost(String author, String title, String contents) throws Exception {
faunaClient.query(
Create(Collection("posts"),
Obj(
"data", Obj(
"title", Value(title),
"contents", Value(contents),
"created", Now(),
"authorRef", Select(Value("ref"), Get(Match(Index("users_by_username"), Value(author))))
)
)
)
).get();
}
如果这看起来很熟悉,它相当于我们之前在 Fauna shell 中创建新帖子时的 Java。
接下来,我们可以添加一个控制器方法来让客户端创建帖子。为此,我们首先需要一条 Java 记录来表示传入的请求数据:
public record UpdatedPost(String title, String content) {}
现在,我们可以在PostsController中创建一个新的控制器方法来处理请求:
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("isAuthenticated()")
public void createPost(@RequestBody UpdatedPost post) throws Exception {
String name = SecurityContextHolder.getContext().getAuthentication().getName();
postsService.createPost(name, post.title(), post.content());
}
请注意,我们使用@PreAuthorize*注解来确保请求经过身份验证,然后我们使用经过身份验证的用户的用户名作为新帖子的作者。*
此时,启动服务并向端点发送 POST 将导致在我们的集合中创建一条新记录,然后我们可以使用早期的处理程序检索该记录。
5.2. 更新现有帖子
**这对我们更新现有帖子而不是创建新帖子也很有帮助。**我们将通过接受带有新标题和内容的 PUT 请求并更新帖子以具有这些值来管理此问题。
和以前一样,我们首先需要的是PostsService上的一个新方法来支持这一点:
public void updatePost(String id, String title, String contents) throws Exception {
faunaClient.query(
Update(Ref(Collection("posts"), id),
Obj(
"data", Obj(
"title", Value(title),
"contents", Value(contents)
)
)
)
).get();
}
接下来,我们将处理程序添加到 PostsController中:
@PutMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("isAuthenticated()")
public void updatePost(@PathVariable("id") String id, @RequestBody UpdatedPost post)
throws Exception {
postsService.updatePost(id, post.title(), post.content());
}
请注意,我们使用相同的请求正文来创建和更新帖子。这完全没问题,因为两者具有相同的形状和含义——相关帖子的新细节。
此时,启动服务并向正确的 URL 发送 PUT 将导致该记录被更新。但是,如果我们使用未知的 ID 进行调用,则会收到错误消息。我们可以使用异常处理程序方法修复此问题:
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public void postNotFound() {}
现在,这将导致更新未知帖子的请求返回 HTTP 404。
6. 检索帖子的过去版本
现在我们可以更新帖子了,查看旧版本会很有帮助。
首先,我们将向PostsService添加一个新方法来检索帖子。这需要帖子的 ID 以及我们想要获取的版本(可选)——换句话说,如果我们提供版本“5”,那么我们希望返回版本“4”:
Post getPost(String id, Long before) throws Exception {
var query = Get(Ref(Collection("posts"), id));
if (before != null) {
query = At(Value(before - 1), query);
}
var postResult = faunaClient.query(
Let(
"post", query
).in(
Obj(
"post", Var("post"),
"author", Get(Select(Arr(Value("data"), Value("authorRef")), Var("post")))
)
)
).get();
return parsePost(postResult);
}
**在这里,我们引入At方法,该方法将使 Fauna 返回给定时间点的数据。**我们的版本号只是以微秒为单位的时间戳,因此我们只需询问给定值之前 1μs 的数据即可获取给定点之前的值。
同样,我们需要一个控制器方法来处理传入的调用。我们将其添加到我们的PostsController中:
@GetMapping("/{id}")
public Post getPost(@PathVariable("id") String id, @RequestParam(value = "before", required = false) Long before)
throws Exception {
return postsService.getPost(id, before);
}
现在,我们可以获得各个帖子的单独版本。调用*/posts/321742144715882562将获取该帖子的最新版本,但调用/posts/321742144715882562?before=1643183487660000*将获取紧邻该版本之前的帖子版本。