Spring BootとGridDBを使用したオンラインテキストストレージサービスの構築

コードやテキストを素早く安全に誰かと共有する必要に迫られたことはありますか? PastebinやGitHub Gistのような使いやすいオンラインテキストストレージソリューションのニーズが高まる中、開発者たちはテキストファイルを効率的に保存・共有する方法を模索しています。本ブログ記事では、Spring Bootを使用して独自のオンラインテキストストレージサービスを構築する方法を説明します。ステップバイステップのガイドに従うことで、ユーザーがテキストファイルを簡単に保存および共有できる、強力で安全なプラットフォームを作成する方法を学べます。初心者でも経験豊富な開発者でも、このガイドを読めば、堅牢なテキストストレージサービスを構築するスキルを習得できます。

要件、高レベル設計から始め、最後に Spring Boot と docker-compose を使用した実装を行います。

ソースコード

コードは、griddbnetのGitHubページでご覧いただけます。

$ git clone https://github.com/griddbnet/Blogs.git --branch springboot-snippet

必要条件

✅ 以下の機能要件を満たします。

  1. ユーザーがタイトル付きのテキストデータをシステムに入力する
  2. ユーザーがスニペットのURLをクリックしてコンテンツを表示/編集する
  3. システムが固有のスニペットIDを生成する

❎ 対象外:

  • ユーザー管理(登録、ログイン
  • ユーザー認証および承認
  • ユーザーがカスタムIDを選択する

☑️ 前提条件

  • システムはテキストベースのデータのみをサポートする
  • コンテンツは期限切れにならない
  • 読み取りと書き込みの比率はおよそ5:1である

☑️ 容量

平均して、各スニペットのサイズはおよそ30KBで、最大サイズは10MBです。1ヶ月あたり1000万のスニペットがあり、各スニペットが30KBのストレージを必要とする場合:30/スニペット100万スニペット/月12ヶ月=36億KB/年となり、これは年間3.6TBのストレージに相当します。

ハイレベルデザイン

通常、テキストコンテンツを保存するには、Amazon S3などのオブジェクトストレージを利用する方法と、データベースを採用する方法の2つがあります。本記事では、テキストのスニペットを直接データベース内に保存することで、アーキテクチャを簡素化します。メタデータとコンテンツの両方を同じデータベースに格納します。この戦略により、データの検索速度が向上します。しかし、この方法で大量のテキストを保存すると、データベースの負荷が増大し、データ量が増加するにつれてパフォーマンスに影響が出る可能性があります。

データストレージ

3つのテーブルを使用します。最初のテーブル「users」には、登録ユーザーの名前やメールアドレスなどの情報が保存されます。2番目のテーブル「snippets」には、タイトルや作成日などのテキストメタデータと、その作成者であるユーザーの情報が保存されます。最後に、「storage」テーブルは、ユーザーのエントリーの実際のテキストコンテンツが安全に保存される保管庫です。ユーザーは複数のスニペットを作成できますが、貼り付けは1人のユーザーのみが所有します。

プロジェクトのセットアップ

開発を始めるには、以下のソフトウェアをインストールする必要があります。

Spring Boot プロジェクトの作成

Spring Boot はアプリケーションを構築する高速な方法を提供します。Spring Boot はファイルを編集するためのコードを生成しません。代わりに、アプリケーションを起動すると、Spring Boot は動的に Bean と設定を結線し、アプリケーションコンテキストに適用します。Spring Boot を使用すると、インフラストラクチャよりもビジネス機能に集中することができます。

start.spring.io に移動します。 このサービスはアプリケーションに必要なすべての依存関係を読み込み、ほとんどの設定を行います。 生成をクリックすると、Spring Boot プロジェクトが生成され、zip 形式でダウンロードされます。 このプロジェクトを解凍し、任意の IDE にインポートします。

GridDB とやり取りするには、このプロジェクトに GridDB Java Client を追加する必要があります。 maven pom.xml に以下の依存関係を追加します。


<dependency>
  <groupId>com.github.griddb</groupId>
  <artifactId>gridstore</artifactId>
  <version>5.5.0</version>
</dependency>
<dependency>
  <groupId>com.github.f4b6a3</groupId>
  <artifactId>tsid-creator</artifactId>
  <version>5.2.5</version>
</dependency>

Webアプリケーションの構築

実用面と使いやすさを考慮し、フロントエンドのレンダリングにはThymeleafを選択しました。Thymeleafは理解しやすくデバッグしやすいフローを提供しており、実用的な選択肢となります。Thymeleafにより、追加のパッケージ管理が不要となり、複雑性と潜在的な脆弱性が軽減されます。

フロントエンド

スニペットのリストを表示し、ヘッダーとボディセクションを持つ基本的なHTMLテーブル構造を定義します。ボディ内では、Thymeleafループ(th:each)を使用して、スニペットのリストを繰り返し処理します。最初の列には、スニペットの詳細へのハイパーリンクを作成するために、アンカー要素内にスニペットのタイトルが表示されます。

<div th:if="${snippets.size() > 0}" class="container">
    <table class="table table-hover table-responsive-xl table-striped">
        <thead class="thead-light">
            <tr>
                <th scope="col">Title</th>
                <th scope="col">Created Time</th>
                <th scope="col">Size</th>
            </tr>
        </thead>
        <tbody class="table-group-divider">
            <tr th:each="snippet : ${snippets}">
                <td>
                    <a class="small" th:href="@{/snippets/{id}(id=${snippet.id})}" th:text="${snippet.title}"></a>
                </td>
                <th id="timeAgo" scope="row">[[${snippet.timeAgo}]]</th>
                <th id="contentSizeHumanReadable" scope="row">[[${snippet.contentSizeHumanReadable}]]</th>                        
            </tr>
        </tbody>
    </table>
</div>

そして、これがリストページの見え方です。

HTMLフォームを使用して新しいスニペットを作成します。 th:action 式は、フォームを /snippets/save エンドポイントに POST するように指示します。 th:object=「${snippet}」 は、フォームデータを収集するために使用するモデルオブジェクトを宣言します。

<form th:action="@{/snippets/save}" method="post" enctype="multipart/form-data" th:object="${snippet}"
  id="snippetForm" style="max-width: 550px; margin: 0 auto">

  <div class="p-3">
      <div class="form-group row">
          <label class="col-sm-3 col-form-label" for="title">Title</label>
          <div class="col-sm-9">
              <input type="text" th:field="*{title}" required minlength="2" maxlength="128"
                  class="form-control" id="title" />
          </div>
      </div>

      <div class="form-group row">
          <label class="col-sm-3 col-form-label" for="content">Content</label>
          <div class="col-sm-9">
              <textarea rows="20" cols="80" th:field="*{content}" form="snippetForm" class="form-control"
                  id="content" required/>
          </div>
      </div>

      <div class="form-group row">
          <label class="col-sm-3 col-form-label" for="userId">User</label>
          <div class="col-sm-9">
              <select th:field="*{userId}">
                  <option th:each="user : ${users}" th:text="${user.fullName}" th:value="${user.id}">
              </select>
          </div>
      </div>

      <div class="text-center">
          <input type="submit" value="Save" class="btn btn-primary btn-sm mr-2" />
          <input type="button" value="Cancel" id="btnCancel" class="btn btn-secondary btn-sm" />
      </div>
  </div>
</form>

スニペット作成ページは次のようになります。

GridDB でのデータアクセス

まず、GridDB の基盤となるテーブルまたはコンテナを表す Java POJO クラスを作成します。Lombok の @Data アノテーションをクラスに付与すると、すべてのフィールドのゲッターが自動的に生成され、便利な toString メソッド、およびすべての非一時フィールドをチェックする hashCode および equals の実装が生成されます。また、コンストラクタと同様に、すべての非最終フィールドのセッターも生成されます。

データベースの設計に従って、データアクセスクラスを作成します。

@Data
public class User {
    @RowKey
    String id;
    String email;
    String fullName;
    Date createdAt;
}

@Data
public class Snippet {
    @RowKey
    String id;

    String title;
    String storageId;
    String userId;
    Date createdAt;
    String contentSizeHumanReadable;
}

@Data
public class Storage {
    @RowKey
    String id;

    Blob content;
}

次に、データベース操作の中心となる構成として、GridDBConfig クラスを作成します。 このクラスは、以下の処理を行います。 * GridDB データベースへの接続に必要な環境変数を読み込みます。 * GridDB インスタンスへのデータベース接続を管理する GridStore クラスを作成します。 * 複数の行を管理するための GridDB Collection のコンテナ(テーブル)を作成します。 このコンテナは、リレーショナルデータベースにおけるテーブルにほぼ相当します。コレクションの作成/更新時に、コレクションのカラムレイアウトに対応するオブジェクト名を指定します。また、各コレクションに対して、TQLのWHERE節の条件で頻繁に検索されるカラムのインデックスを追加します。

@Configuration
public class GridDBConfig {

  @Value("${GRIDDB_NOTIFICATION_MEMBER}")
  private String notificationMember;

  @Value("${GRIDDB_CLUSTER_NAME}")
  private String clusterName;

  @Value("${GRIDDB_USER}")
  private String user;

  @Value("${GRIDDB_PASSWORD}")
  private String password;

  @Bean
  public GridStore gridStore() throws GSException {
    // Acquiring a GridStore instance
    Properties properties = new Properties();
    properties.setProperty("notificationMember", notificationMember);
    properties.setProperty("clusterName", clusterName);
    properties.setProperty("user", user);
    properties.setProperty("password", password);
    GridStore store = GridStoreFactory.getInstance().getGridStore(properties);
    return store;
  }

    @Bean
    public Collection<String, User> userCollection(GridStore gridStore) throws GSException {
        Collection<String, User> collection = gridStore.putCollection("users", User.class);
        collection.createIndex("email");
        return collection;
    }

    @Bean
    public Collection<String, Snippet> snippetCollection(GridStore gridStore) throws GSException {
        Collection<String, Snippet> snippetCollection =
                gridStore.putCollection(AppConstant.SNIPPETS_CONTAINER, Snippet.class);
        snippetCollection.createIndex("userId");
        snippetCollection.createIndex("title");
        return snippetCollection;
    }

    @Bean
    public Collection<String, Storage> storageCollection(GridStore gridStore) throws GSException {
        Collection<String, Storage> storageCollection =
                gridStore.putCollection(AppConstant.STORAGES_CONTAINER, Storage.class);
        return storageCollection;
    }
}

サービスレイヤー

Springフレームワークでは、サービスレイヤーは基本的なアーキテクチャレイヤーの1つであり、主にアプリケーションのビジネスロジックの実装を担当します。

SnippetService

メソッド fetchAll : 新しく作成された最初のコレクションからすべてのスニペットをクエリします。

メソッド create: * スニペットのコンテンツをストレージコレクションに保存し、新しいスニペットのメタデータを保存します。* コンテンツのサイズを計算し、その情報を人間が読める形式で保存する。 * 予測不可能で重複せず、読み取り可能なスニペットIDを生成します。 * スニペットのIDとして、タイムソートユニークID(TSID)を選択します。 TSIDを使用することで、ランダムな要素を含むタイムソートIDを取得でき、13文字の文字列として表現できます。

@Service
@RequiredArgsConstructor
public class SnippetService {
    private final Logger log = LoggerFactory.getLogger(SnippetService.class);
    private final Collection<String, Snippet> snippetCollection;
    private final Collection<String, Storage> storagCollection;

    public List<Snippet> fetchAll() {
        List<Snippet> snippets = new ArrayList<>(0);
        try {
            String tql = "SELECT * FROM " + AppConstant.SNIPPETS_CONTAINER + " ORDER BY createdAt DESC";
            Query<Snippet> query = snippetCollection.query(tql);
            RowSet<Snippet> rowSet = query.fetch();
            while (rowSet.hasNext()) {
                snippets.add(rowSet.next());
            }
        } catch (GSException e) {
            log.error("Error fetch all snippet", e);
        }
        return snippets;
    }

    public void create(CreateSnippet createSnippet) {
        try {
            Snippet found = fetchOneByTitle(createSnippet.getTitle());
            if (found != null) {
                return;
            }
            Blob content = snippetCollection.createBlob();
            content.setBytes(1, createSnippet.getContent().getBytes());
            Storage storage = new Storage();
            storage.setId(KeyGenerator.next("obj"));
            storage.setContent(content);
            storagCollection.put(storage.getId(), storage);
            Snippet snippet = new Snippet();
            snippet.setTitle(createSnippet.getTitle());
            snippet.setStorageId(storage.getId());
            snippet.setUserId(createSnippet.getUserId());
            snippet.setCreatedAt(new Date());
            snippet.setContentSizeHumanReadable(toHumanReadableByNumOfLeadingZeros(
                    createSnippet.getContent().getBytes().length));
            snippetCollection.put(KeyGenerator.next("sn"), snippet);
        } catch (GSException e) {
            e.printStackTrace();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

コントローラレイヤー

このレイヤーは、受信したHTTPリクエストの処理を担当します。コントローラはリクエストを受信し、処理し、データを取得または操作するために前のサービスレイヤーとやりとりします。

SnippetsControllerは、/snippets`へのすべてのHTTPリクエストを処理します。

@Controller
@RequestMapping("/snippets")
@RequiredArgsConstructor
public class SnippetsController {
    private static final Logger log = LoggerFactory.getLogger(SnippetsController.class);
    private final SnippetService snippetService;
    private final UserService userService;

    @GetMapping
    String snippets(Model model) {
        List<Snippet> snippets = snippetService.fetchAll();
        List<SnippetView> snippetViews = snippets.stream()
                .map(sn -> SnippetView.builder()
                        .id(sn.getId())
                        .title(sn.getTitle())
                        .createdAt(sn.getCreatedAt())
                        .timeAgo(calculateTimeAgoByTimeGranularity(sn.getCreatedAt(), TimeGranularity.MINUTES))
                        .contentSizeHumanReadable(sn.getContentSizeHumanReadable())
                        .build())
                .collect(Collectors.toList());

        List<User> users = userService.fetchAll();
        model.addAttribute("snippets", snippetViews);
        return "snippets";
    }

    @GetMapping("/new")
    String newSnippet(Model model) {
        List<User> users = userService.fetchAll();
        model.addAttribute("snippet", new CreateSnippet());
        model.addAttribute("users", users);
        return "new_snippet";
    }

    @PostMapping("/save")
    String saveSnippet(@ModelAttribute("snippet") CreateSnippet createSnippet) {
        snippetService.create(createSnippet);
        return "redirect:/snippets";
    }
}

メソッド snippets(Model model): * URL /snippets への GET リクエストを処理します * サービスレイヤーを呼び出してスニペットのリストを取得します * 各スニペットの作成時刻を「時間前」形式にフォーマットすします* スニペットリストのHTMLコンテンツをレンダリングするための View (この場合は snippets) を返します

メソッド newSnippet(Model model): * URL /snippets/new への GET リクエストを処理します。 * Model オブジェクトを使用して、新しいスニペット (CreateSnippet) をビューテンプレートに公開します。 CreateSnippet には、titlecontent などのフィールドが含まれます。 * サービスレイヤーを呼び出して、スニペットデータを保存します

Docker Compose を使用したプロジェクトの実行

プロジェクトを起動するには、人気のコンテナエンジンであるDockerを利用します。まず、次のコマンドを使用してDockerイメージを構築します。

  docker compose -f docker-compose-dev.yml build

Run the app:

  docker compose -f docker-compose-dev.yml up

コマンドの実行が成功すると、ウェブサイト(http://localhost:8080)にアクセスできます。

まとめ

結論として、Java Spring BootとGridDBを使用して基本的なオンラインテキストストレージサービスを作成するのは、シンプルで効果的なプロセスです。Spring Bootにより、開発者はコアな開発作業に集中でき、Thymeleafはバックエンドを初期に必要としないため、テンプレートのプロトタイピングを迅速化します。GridDBは、blobデータ型を使用してテキストコンテンツの保存を効率的に処理します。

サービスを次のレベルに引き上げるために、データベースのストレージを節約するためのデータ圧縮、各テキストのスニペットの閲覧者数を追跡するためのメトリクスの構築、暗号化を検討してください。これらの拡張機能を実装することで、基本的なサービスを強力で機能豊富なプラットフォームに変えることができます。

ブログの内容について疑問や質問がある場合は Q&A サイトである Stack Overflow に質問を投稿しましょう。 GridDB 開発者やエンジニアから速やかな回答が得られるようにするためにも "griddb" タグをつけることをお忘れなく。 https://stackoverflow.com/questions/ask?tags=griddb

Leave a Reply

Your email address will not be published. Required fields are marked *