Contents

Fauna 和 Spring 构建 Web 应用程序

1. 简介

在本文中,我们将使用 Spring 和 Java 17构建由**Fauna 数据库服务 **支持的博客服务的后端。

2. 项目设置

在开始构建服务之前,我们需要执行一些初始设置步骤——具体来说,我们需要创建一个 Fauna 数据库和一个空白 Spring 应用程序。

2.1. 创建数据库

**在开始之前,我们需要一个 Fauna 数据库来使用。**如果我们还没有,我们需要使用 Fauna 创建一个新帐户

完成后,我们可以创建一个新的数据库。给它一个名称和一个区域,并选择不包含演示数据,因为我们要构建自己的模式:

/uploads/faunadb_spring_web_app/1.png

接下来,**我们需要创建一个安全密钥来从我们的应用程序中访问它。**我们可以从数据库中的安全选项卡中执行此操作:

/uploads/faunadb_spring_web_app/3.png

在这里,我们需要选择“服务器”的“角色”,并且可以选择为键命名。这意味着密钥可以访问这个数据库,但只能访问这个数据库。或者,我们有一个“管理员”选项,可用于访问我们帐户中的任何数据库:

/uploads/faunadb_spring_web_app/5.png

完成后,我们需要写下我们的秘密。这是访问服务所必需的,但出于安全原因,离开此页面后无法再次获取

2.2. 创建一个 Spring 应用程序

**一旦我们有了我们的数据库,我们就可以创建我们的应用程序。**由于这将是一个 Spring webapp,我们最好从Spring Initializr 引导它。

我们想选择使用最新版本的 Spring 和最新的 LTS 版本的 Java 创建 Maven 项目的选项——在撰写本文时,它们是 Spring 2.6.2 和 Java 17。我们还想选择 Spring Web 和 Spring安全性作为我们服务的依赖项:

/uploads/faunadb_spring_web_app/7.png

一旦我们在这里完成,我们可以点击“生成”按钮下载我们的启动项目。

接下来,我们需要将 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. 创建用户集合

**我们要做的第一件事是创建集合。**这是通过导航到我们数据库中的集合屏幕,使用“新集合”按钮并填写表格来完成的。在这种情况下,我们要使用默认设置创建一个“用户”集合:

/uploads/faunadb_spring_web_app/9.png

接下来,我们将添加一条用户记录。为此,我们按下集合中的“新建文档”按钮并提供以下 JSON:

{
  "username": "blogdemo",
  "password": "Pa55word",
  "name": "Blogdemo"
}

请注意,我们在这里以明文形式存储密码。请记住,这是一种糟糕的做法,只是为了方便本教程。

最后,我们需要一个索引。每当我们想通过引用以外的任何字段访问记录时,我们都需要创建一个索引来让我们这样做。在这里,我们想通过用户名访问记录。这是通过按“新索引”按钮并填写表格来完成的:

/uploads/faunadb_spring_web_app/11.png

现在,我们将能够使用“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”,它可以让我们搜索具有特定作者的“帖子”记录:

/uploads/faunadb_spring_web_app/13.png

第二个索引将是“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 记录来表示我们正在获取的数据。这将由AuthorPost记录类组成:

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*将获取紧邻该版本之前的帖子版本。