知識庫 / Spring RSS 訂閱

使用Fauna和Spring構建Web應用,為您的第一家客户打造Web應用

Persistence,Spring
HongKong
4
12:18 PM · Dec 06 ,2025

1. 引言

在本文中,我們將使用 Spring 和 Java 17 構建一個由 Fauna 數據庫服務 驅動的博客後端服務。

注意: 本文使用了 Fauna Query 語言的舊版本。請參考 Fauna 的文檔 以獲取最新的查詢語言版本。

2. 項目設置

我們需要在開始構建服務之前執行一些初始設置步驟,具體來説,我們需要創建一個 Fauna 數據庫和一個空白的 Spring 應用。

2.1. 創建 Fauna 數據庫

在開始之前,我們需要一個 Fauna 數據庫進行操作。 如果我們還沒有一個,則需要創建一個新的 Fauna 賬户。

完成創建賬户後,我們可以創建一個新的數據庫。 為此指定一個名稱和區域,並選擇不包含演示數據,因為我們希望構建自己的模式:

接下來,我們需要創建一個安全密鑰以從我們的應用程序訪問它。 可以在數據庫的“安全”選項卡中完成此操作:

在這裏,我們需要選擇“角色”為“Server”,並且可以選擇為密鑰指定一個名稱。 這意味着密鑰可以訪問此數據庫,但僅此數據庫。 此外,我們還有一個“Admin”選項,可用於訪問帳户中的任何數據庫:

完成這些步驟後,我們需要記錄我們的密鑰。 這對於訪問服務是必需的,但離開此頁面後將無法再次獲取它,出於安全原因。

2.2. 創建 Spring 應用

在我們擁有數據庫之後,我們可以創建我們的應用程序。由於這將是一個 Spring Web 應用,因此最好從 Spring Initializr 進行啓動。

我們希望選擇使用最新版本的 Spring 和最新 LTS 版本的 Java(當時編寫時,這些是 Spring 2.6.2 和 Java 17)創建一個 Maven 項目:

完成這些步驟後,我們可以點擊“Generate”按鈕下載我們的啓動項目。

接下來,我們需要將 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 Web 應用後,我們需要一個 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. 創建用户集合

首先,我們想要創建集合。這通過在數據庫中導航到“集合”屏幕,使用“新建集合”按鈕,並填寫表單來完成。在本例中,我們想要創建一個名為“users”的集合,並使用默認設置:

接下來,我們將添加一個用户記錄。為此,我們在集合中的“新建文檔”按鈕上進行操作,並提供以下 JSON:

{
  "username": "baeldung",
  "password": "Pa55word",
  "name": "Baeldung"
}

請注意,我們在這裏以明文形式存儲密碼。請務必記住,這種做法非常糟糕,僅為本教程的便利性而做。

最後,我們需要建立索引。 每次我們想通過除了參考字段之外的任何字段訪問記錄時,都需要創建一個索引,以便我們能夠執行此操作。 在這裏,我們想通過用户名訪問記錄。 這通過按下“新建索引”按鈕並填寫表單來完成:

現在,我們就可以使用“users_by_username”索引編寫FQL查詢來查找我們的用户。 例如:

Map(
  Paginate(Match(Index("users_by_username"), "baeldung")),
  Lambda("user", Get(Var("user")))
)

以上將返回我們之前創建的記錄。

3.2. 使用Fauna進行身份驗證

現在我們已經擁有一個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 配置來設置它。這是標準的 Spring Security 配置,用於連接上述 UserDetailsService

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfiguration {

    @Autowired
    private FaunaClient faunaClient;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(CsrfConfigurer::disable)
            .authorizeHttpRequests(requests -> requests.requestMatchers("/**")
                .permitAll())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new FaunaUserDetailsService(faunaClient);
    }
}

此時,我們可以將標準 @PreAuthorize 註解添加到我們的代碼中,並根據請求的認證詳情是否存在於我們的“users”集合中,決定接受或拒絕請求。

4. 支持列表帖功能

我們的博客服務如果不能支持“帖子”的概念,那就不具有突出的特點。 這些是實際撰寫並可供他人閲讀的博客文章。

4.1. 創建 Posts 集合

正如之前所述,首先需要一個集合來存儲帖子。這個集合創建方式相同,只是名稱改為“posts”,而不是“users”。 我們將包含四個字段:

  • title – 帖子的標題。
  • content – 帖子的內容。
  • created – 帖子創建的時間戳。
  • authorRef – 帖子的作者“users”記錄的引用。

我們還需要兩個索引。第一個索引是“posts_by_author”,它將允許我們搜索具有特定作者的“posts”記錄:

第二個索引是“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"] }
  ]
})

網頁 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"), "baeldung")))
    }
  }
)

在這裏,我們需要確保“authorRef”的值是我們在之前創建的“users”記錄中的正確值。我們通過查詢“users_by_username”索引來獲取ref,通過查找我們的用户名來獲取。

4.2. Posts 服務

現在我們已經支持在 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 服務。這將是一個 Spring 組件,它封裝了 FaunaClient 並使用它來訪問數據存儲:

@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”索引,以便按用户名查詢,而不是按用户記錄的引用。

為此,我們將向 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. Posts 控制器

現在,我們能夠編寫 Posts 控制器,它將允許 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=baeldung 發送請求,並獲取結果:

[
    {
        "author": {
            "name": "Baeldung",
            "username": "baeldung"
        },
        "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": "Baeldung",
            "username": "baeldung"
        },
        "content": "This is my second post",
        "created": "2022-01-25T07:34:38.303614Z",
        "id": "321742153548038210",
        "title": "My Second Post",
        "version": 1643096078350000
    },
    {
        "author": {
            "name": "Baeldung",
            "username": "baeldung"
        },
        "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 殼中創建新帖子的 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 可以獲取該版本立即之前的版本。

7. 結論

在這裏,我們探討了 Fauna 數據庫的一些特性以及如何使用它們構建應用程序。Fauna 還有很多功能尚未在本指南中涵蓋,為什麼不嘗試將其應用於您下一次的項目呢?

正如往常一樣,這裏展示的所有代碼均可在 GitHub 上找到。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.