Spring Boot を使用してフィットネストラッカーウェブアプリケーションを構築する

健康でフィットな状態は、私たちのライフスタイルの一部であるべきです。健康的なライフスタイルを送ることは、長期的な病気の予防に役立ちます。そのための方法の一つは、ワークアウトの記録を付けることです。具体的には、運動の種類、時間、心拍数などを記録します。

この技術記事では、Spring Bootを使用してシンプルなフィットネス追跡ウェブアプリケーションを作成するプロセスを解説します。まず、ワークアウトの記録(いつ、何をしたか、どのくらいの時間か)を保存できるウェブアプリケーションを作成します。また、心拍数データを活用し、時間経過に伴う心拍数を示すスタイリッシュな折れ線グラフに変換します。これは、ワークアウトに視覚的な心拍数を与えるようなものです!すべてのプロセスを小さなステップに分解し、各ステップを詳細に説明していきます。

機能と機能性

このウェブアプリケーションがサポートする機能は以下の通りです:

  • ユーザーは、距離、カロリー、開始時間、終了時間などのワークアウト情報を記録できます。
  • ユーザーは心拍数データを線グラフで確認できます。
  • ユーザーは心拍数ゾーンの分析を確認できます。

この記事では以下の内容は提供しません:* モバイルアプリケーションで利用可能なRESTfulエンドポイント。* ユーザー登録。デモ目的のため、デモユーザーを提供します。* 人気のフィットネスAPI(Fitbit、Google Fit)との統合。* ワークアウトの種類ごとの具体的なメトリクス。

データモデル

完全なデータモデル。

アプリケーションを利用するユーザーの情報を格納するためのテーブル users を作成します。現在はユーザー登録機能がないため、ユーザーのテーブルには名前とメールアドレスのみが格納されます。

次に、フィットネスデータの格納について説明します。メインのテーブルは workouts で、ユーザーが実施したワークアウトの詳細を格納します。

  • id – 各ワークアウトに一意のIDを割り当てます。
  • startTimeendTime – ワークアウトの開始時間と終了時間を格納します。
  • distance – ランニングやサイクリングなどのスポーツは、移動距離で測定されます。
  • calories – ワークアウト中に消費したカロリーを格納します。カロリー計算の式は使用せず、ユーザーが任意の値を入力できます。
  • duration – システムが自動的に入力します。

心拍数を記録するために、heartrate という名前のテーブルを作成します。このテーブルの列は次のとおりです:

  • id – 各心拍数データに一意のIDを割り当てます。
  • workout_id – このデータが属するワークアウトを識別します。
  • value – 心拍数の値を格納します。
  • time – 心拍数の値が変更された日時を格納します。

デモを簡素化するため、心拍数値を手動で入力するオプションは用意しません。代わりに、ワークアウト中に3分ごとにランダムな心拍数値を自動的に生成する関数を作成します。

技術スタック

  • Spring Boot: スタンドアロンで生産環境向けのSpringベースのアプリケーションを最小限の effort で開発するためのオープンソースJavaフレームワーク。Spring Bootは、Spring Javaプラットフォームの「設定より規約」拡張機能で、Springベースのアプリケーション開発時の設定に関する懸念を最小限に抑えることを目的としています。

  • GridDB: タイムシリーズIoTとビッグデータを高速かつ簡単に処理できる次世代オープンソースデータベース。

  • Thymeleaf: Java XML/XHTML/HTML5テンプレートエンジンで、ウェブ環境と非ウェブ環境の両方で動作します。MVCベースのウェブアプリケーションのビュー層でXHTML/HTML5を配信するのに適していますが、オフライン環境でもXMLファイルを処理できます。Spring Frameworkとの完全な統合を提供します。

  • Apache EChart: Webベースの可視化を迅速に構築するための宣言型フレームワーク。Apache EChartsは、20種類以上のチャートタイプを標準で提供し、数十のコンポーネントを組み合わせて自由に使用できます。

  • Maven: Javaプロジェクト向けに主に使用されるビルド自動化ツール。

  • Docker: 開発者がコンテナ化されたアプリケーションの構築、展開、実行、更新、管理を可能にするオープンソースプラットフォーム。

開発環境のセットアップ

まず、インストールする必要があります。Java 17 以降, Maven 3.5+, Docker エンジン, テキストエディター(インテリジ IDEA, or VSCode).

一から始めるには、以下のリンクをクリックしてください。 Spring Initializr Spring Web、Thymeleaf、Lombok、およびSpring Boot DevToolsの依存関係を追加します。その後、生成ボタンをクリックし、ZIPファイルをダウンロードします。ファイルを解凍し、お好みのIDEでプロジェクトを開きます。

次に、必要な依存関係を追加します。GridDBとやり取りするため、次のようにGridDB Java Clientの依存関係を追加する必要があります:

<dependency>
  <groupId>com.github.griddb</groupId>
  <artifactId>gridstore</artifactId>
  <version>5.5.0</version>
</dependency>

完全な pom.xml ファイルは こちら から確認できます。

主要機能の実装

このプロジェクトでは、Controller-Service-Repository (CSR) パターンを使用します。このパターンは、アプリケーションロジックをコントローラー、サービス、リポジトリの3つの独立した層に分割することで、関心事の分離を促進します。各層は特定の責任を持ち、コードの管理が容易になります。

Maven 依存関係を完了した後、以下のフォルダーを作成する必要があります:

src/main/java/com/galapea/techblog/fitnesstracking/
├── config
├── controller
├── entity
├── model
└── service

GridDBデータベースにフィットネス情報を保存するためには、エンティティフォルダー内にエンティティクラスを定義する必要があります。

  • ユーザーを表すドメインオブジェクトを以下の例のように作成します:
@Data
public class User {

    @RowKey
    String id;
    String email;
    String fullName;
    Date createdAt;

}
  • ワークアウト
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Workout {

    @RowKey
    public String id;
    public String title;
    public String type;
    public String userId;
    public Date startTime;
    public Date endTime;
    public Double distance;
    public Double duration;
    public Double calories;
    public Date createdAt;

}
  • 心拍数
@Data
public class HeartRate {

    @RowKey
    String id;
    Date timestamp;
    double value;
    String workoutId;

}

その後、データベース接続を設定する必要があります。Spring BootのGridDB自動設定が利用できないため、GridDBConfigを作成して設定を行います。この設定では、以前に作成したエンティティクラスをデータベース内のコンテナ(テーブル)を表すために使用しています。

@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 {
        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(AppConstant.USERS_CONTAINER, User.class);
        collection.createIndex("email");
        return collection;
    }

    @Bean
    public Collection<String, HeartRate> heartRateCollection(GridStore gridStore) throws GSException {
        Collection<String, HeartRate> heartRateCollection = gridStore.putCollection(AppConstant.HEARTRATE_CONTAINER,
                HeartRate.class);
        heartRateCollection.createIndex("workoutId");
        return heartRatTo access GridDB, we need to get a GridStore instance using the GridStoreFactory. Connecting to GridDB cluster using the fixed list method, we need to specify the following required properties:

1.  `notificationMember` A list of address and port pairs in cluster. It is used to connect to cluster which is configured with FIXED_LIST mode, and specified as follows. (Address1):(Port1),(Address2):(Port2),... This property cannot be specified with neither notificationAddress nor notificationProvider properties at the same time. This property is supported on version 2.9 or later.
2.  `clusterName` A cluster name. It is used to verify whether it matches the cluster name assigned to the destination cluster. If it is omitted or an empty string is specified, cluster name verification is not performed.

### Listing workout
eCollection;
    }

    @Bean
    public Collection<String, Workout> workoutCollection(GridStore gridStore) throws GSException {
        Collection<String, Workout> workoutCollection = gridStore.putCollection(AppConstant.WORKOUT_CONTAINER,
                Workout.class);
        workoutCollection.createIndex("userId");
        workoutCollection.createIndex("title");
        workoutCollection.createIndex("type");
        return workoutCollection;
    }

}

HTMLテンプレート workouts.html を作成し、th:each ディレクティブを使用してワークアウトのリストを表示します:

                    <tbody class="table-group-divider">
                        <tr th:each="workout : ${workouts}">
                            <td id="user" scope="row" th:text="${workout.user} ? ${workout.user.fullName} : ''"></td>
                            <th id="type" scope="row">[[${workout.type}]]</th>
                            <td>
                                <a class="small" th:href="@{/heartrate/{id}(id=${workout.id})}"
                                    th:text="${workout.title}"></a>
                            </td>
                            <th id="distance" scope="row">[[${workout.distance}]]</th>
                            <td id="startTime" scope="row">[[${workout.startTime}]]</td>
                            <th id="endTime" scope="row">[[${workout.endTime}]]</th>
                            <th id="duration" scope="row">[[${workout.durationText}]]</th>
                        </tr>
                    </tbody>

そのテンプレートは、コントローラークラス WorkoutController によって呼び出されます:

@Controller
@RequestMapping("/workouts")
@RequiredArgsConstructor
public class WorkoutController {

    private final WorkoutService workoutService;

    private final UserService userService;

    java.text.SimpleDateFormat formatter = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm");

    @GetMapping
    String workouts(Model model) {
        List<workoutdto> workouts = workoutService.fetchAll();
        workouts.forEach(workout -> {
            try {
                workout.setUser(userService.fetchOneById(workout.getUserId()));
            }
            catch (GSException e) {
                e.printStackTrace();
            }
            String durationText = Helper.getDurationText(workout.getStartTime(), workout.getEndTime());
            workout.setDurationText(durationText);
        });
        model.addAttribute("workouts", workouts);
        model.addAttribute("createWorkout", new CreateWorkout());
        return "workouts";
    }</workoutdto>

メソッド workouts() では、WorkoutService クラスから fetchAll() メソッドを呼び出します。その後、ユーザーインターフェースに戻す前に、ユーザーのフルネームを取得し、期間を人間が読みやすい形式(例: 1h 15m)にフォーマットする必要があります。デフォルトの java.util.Date 形式ではなく、この形式で返す必要があります。新しいワークアウトを作成するための準備として、ビューに新しいモデルを返します。\n次に、サービスクラスでGridDBからデータを取得する必要があります。ここでは、workoutコレクションからすべてのレコードを選択するTQLステートメントを実行するためのクエリオブジェクトを作成し、そのオブジェクトをWorkoutDtoにマッピングします。

public List<workoutdto> fetchAll() {
        List</workoutdto><workoutdto> dtos = new ArrayList<>(0);
        try {
            String tql = "SELECT * FROM " + AppConstant.WORKOUT_CONTAINER + " ORDER BY createdAt DESC";
            Query<workout> query = workoutCollection.query(tql);
            RowSet</workout><workout> rowSet = query.fetch();
            while (rowSet.hasNext()) {
                Workout row = rowSet.next();
                WorkoutDto dto = WorkoutDto.builder()
                    .id(row.id)
                    .title(row.title)
                    .type(WorkoutType.valueOf(row.type))
                    .userId(row.userId)
                    .startTime(row.startTime)
                    .endTime(row.endTime)
                    .distance(row.distance)
                    .duration(row.duration)
                    .calories(row.calories)
                    .createdAt(row.createdAt)
                    .build();
                dtos.add(dto);
            }
        }
        catch (GSException e) {
            log.error("Error fetch all workouts", e);
        }
        return dtos;
    }</workout></workoutdto>

ワークアウトを記録する

ユーザーが新しいワークアウトを記録し「送信」をクリックすると、WorkoutController でフォームの送信処理を以下のメソッドで処理します:

@PostMapping("/record")
    String recordWorkout(@ModelAttribute("createWorkout") CreateWorkout createWorkout,
            final BindingResult bindingResult, RedirectAttributes attributes, Model model) throws ParseException {
        List<user> users = userService.fetchAll();
        java.util.Collections.shuffle(users);
        java.util.Date startDate = formatter.parse(createWorkout.getStartTime());
        java.util.Date endDate = formatter.parse(createWorkout.getEndTime());
        LocalDateTime start = Helper.convertToLocalDateTime(startDate);
        LocalDateTime end = Helper.convertToLocalDateTime(endDate);
        if (end.isBefore(start)) {
            bindingResult.rejectValue("endTime", "error.endTime", "End-date must be after Start-date!");
            attributes.addAttribute("hasErrors", true);
        }

        if (bindingResult.hasErrors()) {
            return workouts(model);
        }

        WorkoutDto workoutDto = WorkoutDto.builder()
            .title(createWorkout.getTitle())
            .type(WorkoutType.valueOf(createWorkout.getType()))
            .userId(users.get(0).getId())
            .distance(Double.parseDouble(createWorkout.getDistance()))
            .startTime(startDate)
            .endTime(endDate)
            .calories(Double.parseDouble(createWorkout.getCalories()))
            .build();
        workoutService.create(workoutDto);
        attributes.addFlashAttribute("message", "Workout recorded!");
        return "redirect:/workouts";
    }</user>

デモ目的で、既存のユーザーからランダムにユーザーを選択します。その後、コントローラーはWorkoutServicecreateメソッドを呼び出します。このメソッド内で、ワークアウトの持続時間を計算します。行を挿入した後、ApplicationEventPublisherを使用してWorkoutCreatedイベントを発行し、HeartRateServiceで消費されます。

public void create(WorkoutDto workoutDto) {
        try {
            Double duration = workoutDto.getDuration() == null ? 0.0 : workoutDto.getDuration();
            if (workoutDto.getId() == null) {
                workoutDto.setId(KeyGenerator.next("wk_"));
            }
            Workout workout = Workout.builder()
                .id(workoutDto.getId())
                .title(workoutDto.getTitle())
                .type(workoutDto.getType().name())
                .userId(workoutDto.getUserId())
                .startTime(workoutDto.getStartTime())
                .endTime(workoutDto.getEndTime())
                .distance(workoutDto.getDistance())
                .duration(duration)
                .calories(workoutDto.getCalories())
                .createdAt(new java.util.Date())
                .build();

            if (workoutCollection.get(workoutDto.getId()) == null) {
                workoutCollection.put(workoutDto.getId(), workout);
                applicationEventPublisher.publishEvent(mapStructMapper.workoutToWorkoutCreated(workout));
            }
        }
        catch (GSException e) {
            log.error("Error create a workout", e);
        }
    }

ご存知の通り、最初に心拍数データを記録するとお伝えしましたが、入力するためのインターフェースが見当たりません。このチュートリアルではウェアラブルデバイスとの接続を行わないため、各ワークアウトごとにダミーの心拍数データを作成することにしました。ロジックは、ワークアウトの持続時間ごとに3分ごとにランダムな心拍数値を生成するもので、以下のHeartRateServiceクラスに実装されています。

public void generateHeartRate(WorkoutCreated workout) {
        Faker faker = new Faker();
        LocalDateTime start = Helper.convertToLocalDateTime(workout.getStartTime());
        LocalDateTime end = Helper.convertToLocalDateTime(workout.getEndTime());
        for (LocalDateTime date = start; date.isBefore(end); date = date.plusMinutes(3)) {
            HeartRate heartRate = new HeartRate();
            heartRate.setWorkoutId(workout.getId());
            heartRate.setValue(faker.number().randomDouble(0, 50, 190));
            heartRate.setTimestamp(Date.from(date.toInstant(ZoneOffset.UTC)));
            heartRate.setId(KeyGenerator.next("hr_"));
            try {
                heartRateCollection.put(heartRate);
                log.debug("Append: {}", heartRate);
            }
            catch (GSException e) {
                log.error("Error generateHeartRate", e);
            }
        }
    }

ダミーワークアウトの生成

可視化に移る前に、デモ目的でダミーワークアウトを生成する方法を説明します。ダミーデータを生成する際には、Java Fakerというライブラリを使用します。このライブラリは、住所からポップカルチャーの参照まで、多様な現実的なデータを生成するのに役立ちます。

@PostMapping("/generate-dummy")
    String generateDummyWorkout(RedirectAttributes attributes) {
        List<user> users = userService.fetchAll();
        List<workouttype> types = Arrays.asList(WorkoutType.values());
        Faker faker = new Faker();
        for (int i = 0; i < 2; i++) {
            LocalDateTime startDateTime = LocalDateTime.now().minusMinutes(faker.number().randomNumber(2, true));
            LocalDateTime endDateTime = LocalDateTime.now().minusMinutes(faker.number().numberBetween(1, 3));
            if (endDateTime.isBefore(startDateTime)) {
                continue;
            }

            Collections.shuffle(users);
            Collections.shuffle(types);
            WorkoutDto workoutDto = WorkoutDto.builder()
                .id(KeyGenerator.next("wk_"))
                .title(faker.weather().description() + " - " + faker.address().streetAddress() + ", "
                        + faker.address().cityName())
                .type(types.get(0))
                .userId(users.get(0).getId())
                .distance(Helper.calculateAverageDistanceInKm(startDateTime, endDateTime))
                .calories(faker.number().randomDouble(0, 50, 500))
                .startTime(Date.from(startDateTime.toInstant(ZoneOffset.UTC)))
                .endTime(Date.from(endDateTime.toInstant(ZoneOffset.UTC)))
                .build();
            workoutService.create(workoutDto);
        }
        attributes.addFlashAttribute("message", "Generated dummy workout!");
        return "redirect:/workouts";
    }</workouttype></user>

心拍数可視化と分析

次に、可視化の部分に進みます。最初のステップは、選択したワークアウトの心拍数を時間軸に沿って可視化することです。線グラフの作成にはApache EChartを使用します。

  1. CDNからEChartライブラリをインクルードします
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"
        integrity="sha256-6EJwvQzVvfYP78JtAMKjkcsugfTSanqe4WGFpUdzo88=" crossorigin="anonymous"></script>
  1. チャートを格納するためのDOM要素を作成する
<div class="chart-area">
  <div id="heartrateChart" style="height:600px;"></div>
</div>
  1. EChartのインスタンスを作成し、DOMコンテナにバインドします。タイトル、x軸のタイプ(カテゴリ)、y軸の数値データ用の値を定義し、データセットを表すシリーズを定義します。
var myChart = echarts.init(document.getElementById('heartrateChart'));
const heartRateZoneSummaries = /*[[${heartRateZoneSummaries}]]*/[]
const _data = /*[[${heartRates}]]*/[
    { timestampLabel: "2024/01/01 04:18", value: 10 },
    { timestampLabel: "2024/01/02 04:23", value: 5 }
];
var option = {
    title: {
        text: 'Heart Rate',
        left: '1%'
    },
    tooltip: {
        trigger: 'axis'
    },
    grid: {
        left: '10%',
        right: '15%',
        bottom: '10%'
    },
    xAxis: {
        type: 'category',
        boundaryGap: false,
        data: _data.map(x => x.timestampLabel)
    },
    yAxis: {
        type: 'value',
        boundaryGap: [0, '100%']
    },
    toolbox: {
        right: 10,
        feature: {
            dataZoom: {
                yAxisIndex: 'none'
            },
            restore: {},
            saveAsImage: {}
        }
    },
    series: [
        {
            name: 'Heart Rate',
            type: 'line',                    
            data: _data.map(x => x.value),
            markLine: {
                silent: true,
                lineStyle: {
                    color: '#333'
                },
                data: [
                    {
                        yAxis: 50
                    },
                    {
                        yAxis: 100
                    },
                    {
                        yAxis: 150
                    },
                    {
                        yAxis: 200
                    },
                    {
                        yAxis: 250
                    }
                ]
            }
        }
    ],
};
// Display the chart using the configuration items and data just specified.
myChart.setOption(option);

HTMLでチャートオブジェクトを定義した後、コントローラークラスから心拍数データを渡す必要があります。\ HeartRateController

@GetMapping("/{id}")
    String heartRate(Model model, @PathVariable("id") String id) {
        log.info("Fetch heart rate of workout: {}", id);
        HeartRateDashboard heartRateDashboard = heartRateService.fetchForDashboardByWorkoutId(id);
        log.info("heart rate size: {}", heartRateDashboard.heartRates().size());
        model.addAttribute("heartRates", heartRateDashboard.heartRates());
        model.addAttribute("workout", workoutService.fetchById(id));
        model.addAttribute("heartRateZoneSummaries", heartRateDashboard.heartRateZoneSummaries());
        return "heartrate";
    }

コントローラーは、心拍数の一覧とゾーンの要約をビューに送信します。以下にDTOクラスを示します:

public record HeartRateDashboard(List<heartratedto> heartRates, List<heartratezonesummary> heartRateZoneSummaries) {}

@Data
public class HeartRateDto {
    private String timestampLabel;
    private double value;
}

@Data
@Builder
public class HeartRateZoneSummary {
    String zoneValue;
    long durationInMinuets;
}</heartratezonesummary></heartratedto>

In the service class, we query the heart rate container by workout ID and then format the timestamp into our desired format “2024/01/01 04:18”. Besides that, we categorize the heart rate data into 3 zones which are the Peak Zone, Cardio Zone, and Fat Burn Zone.

HeartRateService:

public HeartRateDashboard fetchForDashboardByWorkoutId(String workoutId) {
        List<heartratedto> heartRates = new ArrayList<>(0);
        List<heartrate> heartRatesList = getHeartRatesList(workoutId);
        heartRates = heartRatesList.stream()
            .map(hr -> HeartRateDto.builder()
                .timestampLabel(getTimestampLabel(hr.getTimestamp()))
                .value(hr.getValue())
                .build())
            .collect(Collectors.toList());

        List<heartratezonesummary> heartRateZoneSummaries = HeartRateAnalyzer.analyze(heartRatesList);

        return new HeartRateDashboard(heartRates, heartRateZoneSummaries);
    }

    private List<heartrate> getHeartRatesList(String workoutId) {
        List</heartrate><heartrate> heartRatesList = new ArrayList<>(0);
        try {
            Query</heartrate><heartrate> query = heartRateCollection.query("SELECT * WHERE workoutId='" + workoutId + "'",
                    HeartRate.class);
            RowSet</heartrate><heartrate> rowSet = query.fetch();
            while (rowSet.hasNext()) {
                HeartRate row = rowSet.next();
                log.debug("Fetched: {}", row);
                heartRatesList.add(row);
            }
        }
        catch (GSException e) {
            log.error("Error query heart rate", e);
        }
        return heartRatesList;
    }</heartrate></heartratezonesummary></heartrate></heartratedto>

ついにダッシュボードが完成しました:

私たちは、このマルチコンテナDockerアプリケーション(Spring BootアプリケーションとGridDB)の定義と実行にDocker Composeを使用しています。プロジェクトを実行するには、Githubの手順に従ってください。

結論

このチュートリアルでは、Spring Bootを使用して堅牢なMVCアプリケーションを作成する力を探求しました。GridDBをデータ永続化として統合する方法を示しました。Thymeleafのようなフロントエンド技術の統合は、Spring BootがさまざまなUIテンプレートに対応できる柔軟性を示しました。rawフィットネスデータを意味のある可視化に変換する方法を学びました。

このアプリケーションを以下の機能で拡張できます:* ワークアウト一覧にページネーションを実装する* ウェアラブルデバイスで使用できるAPIを提供するか、フィットネスAPIプロバイダーからワークアウトの詳細を取得する

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