Spring BootとGridDBを使ったブログ投票ウェブ・プラットフォームの構築

オンライン投票システムとは、直感的で安全なオンライン・インターフェイスを使って、人々が電子的に投票できるようにするソフトウェア・プラットフォームです。これらのシステムは、投票プロセス全体を促進するために最先端の技術を活用しています。オンライン投票システムには、安全なウェブポータルからモバイルアプリケーションまで、さまざまな形態があります。

オンライン投票システムは、アクセシビリティが課題となっている状況で輝きを放ち、移動の問題に直面する可能性のある人や地理的に離れた場所にいる人がシームレスに参加できるようにします。さらに、オンライン投票システムは、大規模な選挙において非常に貴重であり、投票プロセスを迅速化し、手作業による集計に必要な時間とリソースを大幅に削減します。

フォローする方法

私たちのリポジトリからソースコードを入手できます:

git clone https://github.com/alifruliarso/spring-boot-blog-voting.git –branch voting-system

オンライン投票はどこで最も役に立つでしょうか?

オンライン投票システムを使うのは良いアイデアです:

  • 方針決定による規則や規定への投票
  • 受賞候補の選択
  • 従業員から匿名のフィードバックを集める

私たちが構築しているもの

このガイドでは、Spring BootとNoSQLを使ってシンプルな投票システムを作り、アプリケーションをDockerコンテナにパッケージングする旅に出ます。私たちは、ユーザーがブログ記事に対して意見を表明する力を与え、議論やフィードバックの活気あるコミュニティを育成することを目指しています。

要件

これまでの概要に基づき、以下の機能要件があります:

  1. シンプルなフォームでブログを作成する
  2. ブログ記事への投票(アップヴォートまたはダウンヴォート)。ログイン/ログアウトはなく、ランダムにユーザーを生成する。
  3. 投票結果を静的かつリアルタイムのチャートで可視化する。

範囲外: * ユーザー管理 (登録、ログインなど) * ブログ管理 (作成、編集、削除)

データベース

SQLデータベースとNoSQLデータベースの選択は、特定の要件に依存します。しかし、このシステムでSQLデータベースよりもNoSQLデータベースが好まれる理由は以下の通りです:

  1. 水平方向のスケーラビリティ: NoSQLデータベースは通常、水平方向のスケーラビリティが容易である。
  2. クエリ構造: この場合、テーブル結合のような複雑なリレーショナル操作は必要ない。
  3. NoSQLデータベースは通常、分散分散アーキテクチャで構築されており、クラスタにノードを追加することで容易にスケーリングできる。

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

インストールに必要なもの

Spring Bootプロジェクトの作成

Spring Bootはアプリケーションを高速にビルドする方法を提供します。Spring Bootはファイルを編集するコードを生成しません。その代わり、アプリケーションを起動すると、Spring BootがBeanと設定を動的に配線し、アプリケーションコンテキストに適用します。Spring Bootを使えば、ビジネス機能にもっと集中でき、インフラにはあまり集中できません。

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

GridDBとやりとりするために、このプロジェクトにGridDB Java Clientを追加する必要があります。maven pom.xmlに以下の依存関係を追加する。 xml com.github.griddb gridstore 5.3.0 #データベースの設計

データベースの設計

それでは、機能要件に従ってデータベース・スキーマを書き出してみましょう。以下のエンティティクラスがあります: * User

システム内のユーザー情報を保持する。以下の属性を持ちます:

*   `String id`: System generated unique identifier. It is the primary key.
*   `String email`
*   `String fullName`
  • Blog

    このエンティティはブログ記事を保存します。以下の属性を持ちます:

    • String id: System generated unique identifier. It is the primary key.
    • String title: The title of the blog post
    • Integer voteUpCount: The count of vote up
    • Integer voteDownCount: The count of votes down
    • Date createdAt: System-generated timestamp when the blog post created
  • VoteMetrics

    このエンティティはブログ記事を保存します。以下の属性を持ちます:

    • Date timestamp: System generated. It is the primary key for the time series container (table).
    • String blogId: The blog ID voted by the user
    • String userId: The user ID makes voting
    • Integer voteType: The voting type, 1: Vote Up, 0: Vote Down

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 Blog {
    @RowKey
    String id;
    String title;
    Integer voteUpCount;
    Integer voteDownCount;
    Date createdAt;
}

@Data
public class VoteMetrics {
    @RowKey
    Date timestamp;
    String blogId;
    String userId;
    Integer voteType;
}

次に、データベース操作の中心的な設定として GridDBConfig クラスを作成します。このクラスは以下のことを行います: * GridDBインスタンスへのデータベース接続を管理するためのGridStoreクラスを作成します。コンテナは、リレーショナル・データベースのテーブルに相当します。* Collectionの作成・更新時には、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, Blog> blogCollection(GridStore gridStore) throws GSException {
        Collection<String, Blog> collection = gridStore.putCollection("blogs", Blog.class);
        collection.createIndex("title");
        return collection;
    }

    @Bean
    public TimeSeries<votemetrics> voteMetricContainer(GridStore gridStore) throws GSException {
        TimeSeries</votemetrics><votemetrics> timeSeries = gridStore.putTimeSeries(Constant.VOTEMETRICS_CONTAINER, VoteMetrics.class);
        timeSeries.createIndex("blogId");
        timeSeries.createIndex("userId");
        return timeSeries;
    }
}</votemetrics>

サービス・クラス

次に、ビジネス・ロジックを処理するための @Service アノテーションを持つサービス・クラスを作成し、Entity クラスを DTO に変換します。

BlogService.javaは、GridDBのブログ記事の検索と作成を担当します。

  • create: 新しいブログの作成

public Blog create(CreateBlogRequest createBlogRequest) {
    Blog blog = new Blog();
    blog.setId(KeyGenerator.next("bl"));
    blog.setTitle(createBlogRequest.getTitle());
    blog.setVoteDownCount(0);
    blog.setVoteUpCount(0);
    blog.setCreatedAt(new Date());
    try {
        blogCollection.put(blog);
        blogCollection.commit();
    } catch (GSException e) {
        log.error("Error create blog", e);
    }
    return blog;
}
  • fetchAll:すべてのブログ記事を取得

  public List<blog> fetchAll() {
      List</blog><blog> blogs = new ArrayList<>(0);
      try {
          Query</blog><blog> query = blogCollection.query("SELECT *", Blog.class);
          RowSet</blog><blog> rowSet = query.fetch();
          while (rowSet.hasNext()) {
              blogs.add(rowSet.next());
          }
      } catch (GSException e) {
          log.error("Error fetchAll", e);
      }
      return blogs;
  }</blog>
  • updateVoteUp: を使用して、投票アップアクションを保存します。このメソッドでは、行を取得する際に、get(key, forUpdate)メソッドを使用して更新したい行をロックし、トランザクションが完了するかタイムアウトが発生するまで他の更新操作を待たせます。

  public void updateVoteUp(String blogId) throws GSException {
      blogCollection.setAutoCommit(false);
      Blog blog = blogCollection.get(blogId, true);
      blog.setVoteUpCount(blog.getVoteUpCount() + 1);
      blogCollection.put(blog);
      blogCollection.commit();
  }

投票メトリクスの行の例です。


VoteMetrics(timestamp=Sun Jan 21 03:48:10 GMT 2024, blogId=bl_0EWG3KRE8H95G, userId=us_0EWQES1BVRJAN, voteType=1)
VoteMetrics(timestamp=Sun Jan 21 03:48:03 GMT 2024, blogId=bl_0EWG3KRDCHBEM, userId=us_0EWQER8KKRHDF, voteType=1)
VoteMetrics(timestamp=Sun Jan 21 03:48:02 GMT 2024, blogId=bl_0EWQ49HPKRKWB, userId=us_0EWQER2KVRJCJ, voteType=1)
VoteMetrics(timestamp=Sun Jan 21 03:48:00 GMT 2024, blogId=bl_0EWG3KRE8H95G, userId=us_0EWQEQV6FRKKV, voteType=1)

Spring MVCでWebコンテンツを扱う

SpringのWeb MVCフレームワークは、他の多くのWeb MVCフレームワークと同様に、リクエスト駆動型で、リクエストをコントローラにディスパッチし、Webアプリケーションの開発を容易にする他の機能を提供する中央のServletを中心に設計されています。Spring MVCフレームワークを使うことで、次のような利点があります: * Spring MVCは、モデルオブジェクト、コントローラ、コマンドオブジェクト、ビューリゾルバなど、それぞれの役割を特化したオブジェクトで実現できるように分離しています。* アプリケーションの開発とデプロイに軽量なサーブレットコンテナを使用します。* フレームワークとアプリケーション・クラスの両方に堅牢なコンフィギュレーションを提供し、ウェブ・コントローラからビジネス・オブジェクトへのような、コンテキストを越えた容易な参照を含みます。* ページを簡単にリダイレクトする特定のアノテーションを提供します。

このチュートリアルでは、標準的なMVCアーキテクチャに従います。コントローラ (VotesController クラス)、ビュー (votes.html Thymeleaf テンプレート)、そしてビューにデータを渡すためのモデル (Java マップオブジェクト) を用意します。コントローラの各メソッドは URI にマップされます。

投票ページ

以下の例では、VotesController のメソッド votes/votes に対する GET リクエストを処理してビュー名 (この場合は votes) を返し、addAttribute メソッドによって Model に属性 blogs を追加しています。


@Controller
@RequestMapping("/votes")
public class VotesController {
    
  @GetMapping
  String votes(Model model) {
      List<blog> blogs = blogService.fetchAll();
      model.addAttribute("blogs", blogs);
      return "votes";
  }

  @GetMapping("/up/{id}")
    public String voteUp(@PathVariable("id") String blogId, Model model, RedirectAttributes redirectAttributes) {
        try {
            String userId = KeyGenerator.next("us");
            voteService.voteUp(blogId, userId);
            redirectAttributes.addFlashAttribute("message", "Voting successful!");
        } catch (Exception e) {
            redirectAttributes.addFlashAttribute("message", "Oh no!");
        }
        return "redirect:/votes";
    }
}
</blog>

コントローラクラスを作成した後は、生成するビューのテンプレートを定義する必要があります。私たちは Thymeleaf を使っています。Thymeleaf は最新のサーバサイドJavaテンプレートエンジンで、ウェブ環境とスタンドアロン環境の両方に対応しています。Thymeleafで書かれたHTMLテンプレートはHTMLのように見え、動作します。

ブログ記事のリストを表示するためにHTMLテーブルを定義し、ブログ記事のコレクションを繰り返し表示するためにth:eachタグ属性を使用し、値を表示するためにth:textタグを使用します。


  <tbody>
      <tr th:each="blog : ${blogs}">
          <th scope="row">[[${blog.title}]]</th>
          <td>
              <a th:href="@{'/votes/up/' + ${blog.id}}" title="Vote Up" class="btn btn-outline-success"
                  role="button">
                  <i class="fa fa-thumbs-up"></i>
                  <span type="text" th:text="${blog.voteUpCount}" class="btn-label"></span>
              </a>
          </td>
          <td>
              <a th:href="@{'/votes/down/' + ${blog.id}}" title="Vote Down" class="btn btn-outline-danger"
                  role="button">
                  <i class="fa fa-thumbs-down"></i>
                  <span type="text" th:text="${blog.voteDownCount}" class="btn-label"></span>
              </a>
          </td>

          <td>[[${blog.createdAt}]]</td>
      </tr>
  </tbody>

投票ページのプレビューです。ユーザはサムズアップ/ダウンアイコンをクリックして、投稿を上下に投票できるはずです。

ダッシュボード・ページ

リアルタイムのダッシュボードには、サーバー送信イベントを使用します。サーバ送信イベントを使うと、サーバはメッセージをウェブページにプッシュすることで、いつでも新しいデータをウェブページに送信することができます。これらの受信メッセージは、ウェブページ内でイベント + データとして扱うことができます。

サーバーからのイベントの受信を開始するためにサーバーへの接続を開くには、イベントを生成するスクリプトの URL(/votes/chart-data)で新しい EventSource オブジェクトを作成します。

script
const ctx = document.getElementById('charts');
const voteChart = new Chart(ctx, config);
/* A small decorator for the JavaScript EventSource API that automatically reconnects */
const eventSource = new ReconnectingEventSource("/votes/chart-data");

ここでは、EventSourceがメッセージ・イベントの受信をリッスンし、チャート・データセットを更新します。

script
eventSource.onmessage = function (event) {
    console.log("Received event: " + event.data);
    const data = JSON.parse(event.data);
    config.data.labels.splice(0, config.data.labels.length, ...data.map(row => row.label));
    config.data.datasets[0].data.splice(0, config.data.datasets[0].data.length, ...data.map(row => row.count));
    voteChart.update();
};

イベントを送るサーバー側のスクリプトは、MIMEタイプtext/event-streamを使って応答しなければなりません。各通知は改行で終了するテキストブロックとして送信されます。


@GetMapping("/chart-data")
public SseEmitter streamSseMvc(@RequestHeader Map<String, String> headers) {
    SseEmitter emitter = new SseEmitter(Duration.ofMinutes(15).toMillis());
    ExecutorService sseMvcExecutor = Executors.newSingleThreadExecutor();
    sseMvcExecutor.execute(() -> {
        try {
            for (int i = 0; true; i++) {
                SseEventBuilder event = SseEmitter.event()
                        .data(voteMetricService.getVoteAggregateOverTime())
                        .id(eventId);
                emitter.send(event);
                Thread.sleep(Duration.ofSeconds(30).toMillis());
            }
        } catch (Exception ex) {
            emitter.completeWithError(ex);
        }
        emitter.complete();
    });
    return emitter;
}

このプロジェクトでは、SseEmitterがタイムアウトになるまで永遠にイベントを送信します。サーバーは30秒ごとに新しいデータを送信します。

SSEエンドポイントからの応答:

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

プロジェクトを立ち上げるために、一般的なコンテナ・エンジンであるDockerを利用します。コンテナは、アプリケーションがどのマシンでも期待通りに動作することを保証するのに役立ちます。Dockerでは、複数のコンテナ化されたアプリケーションを一緒にオーケストレーションするためのツールであるDocker Composeにアクセスできます。

まず、dockerfileを作成します: dev.Dockerfile`を作成します。JDK21のMaven dockerイメージを使用します。


FROM maven:3.9.5-eclipse-temurin-21-alpine
RUN mkdir /app
WORKDIR /app

COPY pom.xml ./
RUN mvn dependency:go-offline

COPY docker-entrypoint-dev.sh ./
COPY src ./src

次に、docker entry-point ファイルを作成します: docker-entrypoint-dev.sh`を作成します。このスクリプトで、コードが変更されるたびにコンパイルするようにしたいです。


#!/bin/bash
export TERM=xterm
echo "wait 5s"
sleep 5

mvn spring-boot:run -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" &

while true; do
    watch -d -t -g "ls -lR . | sha1sum" && mvn compile
done

最後に、docker-composeファイルを作成します: docker-compose-dev.yml`を作成します。


version: '3.3'
services:
  app-dev:
    container_name: blogvotingapp-dev
    build:
      context: ./
      dockerfile: dev.Dockerfile
    volumes:
      - ./src:/app/src
      - ./.m2:/root/.m2
    environment:
      - GRIDDB_NOTIFICATION_MEMBER=griddb-dev:10001
      - GRIDDB_CLUSTER_NAME=dockerGridDB
      - GRIDDB_USER=admin
      - GRIDDB_PASSWORD=admin
      - spring.thymeleaf.prefix=file:src/main/resources/templates/
    command: sh ./docker-entrypoint-dev.sh
    ports:
      - 8080:8080
      - 35729:35729
      - 5005:5005
    networks:
      - griddbvoting-dev-net
    depends_on:
      - griddb-dev
  griddb-dev:
    container_name: griddbvoting-dev
    build:
      context: ./griddbdocker
      dockerfile: Dockerfile531
    volumes:
      - griddbvoting-dev-vol:/var/lib/gridstore
    ports:
      - 10001:10001
      - 20001:20001
    networks:
      - griddbvoting-dev-net

networks:
  griddbvoting-dev-net:
volumes:
  griddbvoting-dev-vol:

以下のコマンドでドッカーイメージをビルドしてみましょう:


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

Dockerイメージをビルドしたら、今度はそれを実行します:


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

ウェブサイトは http://localhost:8080 で準備完了です

まとめ

Spring BootとデータベースとしてGridDBを使って投票プラットフォームプラットフォームを作る方法を学びました。

また、Docker Composeを使ってSpring Bootアプリケーションを開発する方法も学びました。Spring BootアプリケーションをDocker化することで、デプロイプロセスが大幅に簡素化され、様々なアプリケーションで一貫した環境が保証されます。また、アプリケーションとその依存関係をDockerイメージにカプセル化することで、潜在的な一貫性と競合を大幅に削減します。

ブログの内容について疑問や質問がある場合は 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 *