見出し画像

【Java&SpringBoot3】 早起きのWebアプリー機能追加(3)「いいね」機能(設計・実装・テスト)



1. はじめに


こんにちは!
今日は昨日に続き「いいね」機能の設計・実装・テストまで、Waterfall工程方式の流れで開発してみます!


2. 「いいね」機能の基本設計(08.07-08.08)


2.1 目的

関心のあるユーザー(フォロー)の記録に「いいね」を登録し、朝起きのモチベーションをさら向上します。

2.2 機能構成

  1. フォロー中のユーザーの記録に ハートボタンを押すと「いいね」 数が1増加する。

  2. 同じハートボタンをもう一度押すと「いいね」 数が1減少する。

  3. フォローしていないユーザーの記録のハートボタンを押すと、「フォローしていません」というメッセージを表示する。


2.3 画面構成

KakaoOvenというUI構成ツールで作成しますた。

1. いいねボタンの形はハート型にする。
ボタンの上の位置は、記録を「見る」ボタンの右側に位置する。いいねの数はハート型ボタンの右側に位置します。
2. ハート型ボタンを押すと、いいね数が1つ増える。
3. 再びハート型ボタンを押すと、いいね数が1つ減ります。
4. フォローしていないユーザーの投稿のいいねボタンを押すと
フォローしていないというメッセージを表示する。

2.4 データベースモデリング

like テーブルの articleId は article テーブルの id フィールドを参照します。
like テーブルの userId は article テーブルの author フィールドを参照します。
article テーブルの author フィールドは user テーブルの email を参照します。


3. 「いいね」機能の詳細設計と実装(08.09-08.11)


3.1 UML - シーケンスダイアグラム

いいね登録
いいねキャンセル

3.2 Domain - Like.java

@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "likes")
@Data
public class Like {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "article_id")
    private Long articleId;

    @Column(name = "user_id")
    private String userId;

    @CreatedDate
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Builder
    public Like(Long articleId, String userId) {
        this.articleId = articleId;
        this.userId = userId;
    }


}

id
いいねエンティティの固有の識別子です。データベースで自動的に生成され、主キー(primary key)の役割をします。

articleId
いいねした記事のIDを表すフィールドです。 このフィールドは記事エンティティと関連付けられ、特定の記事に対するいいねを表示します。

userId
いいねが適用される記事の登録者のIDを表すフィールドです。記事の登録者がフォローされた対象であるかを識別することができます。

createdAt
エンティティが作成された時間を表すフィールドです。CreatedDateアノテーションを使用してエンティティが生成される時、自動的に時間情報が記録されます。


3.3 Repository - LikeRepository.java

public interface LikeRepository extends JpaRepository<Like, Long> {
    boolean existsByarticleIdAndUserId(Long articleId, String userId);
      
    int countByarticleId(Long articleId);

    void deleteByarticleIdAndUserId(Long articleId, String userId);

}


boolean existsByarticleIdAndUserId(Long articleId, String userId);

このメソッドはlikesテーブルで特定のarticleIdとuserIdに該当するデータが存在するか確認します。サービスエリアメソッド内部でコード進行の初期に値を確認してアーリーリターンできるようにします。メソッド名から自動的に生成されるクエリで、該当条件に合うデータが存在する場合はtrueを返し、そうでない場合はfalseを返します。

int countByarticleId(Long articleId);

いいねボタンの右側にいいね数を返すためのメソッドです。このメソッドはlikesテーブルで特定のarticleIdに該当するデータの数を数えて返します。メソッド名から自動的に生成されるクエリで、その articleId に該当する likes の数を返します。

void deleteByarticleIdAndUserId(Long articleId, String userId);

このメソッドはlikesテーブルから特定のarticleIdとuserIdに該当するデータを削除します。メソッド名から自動的に生成されるクエリで、その条件に該当するデータを削除します。


3.4 Service - LikeService.java

@Service
public class LikeService {

    @Autowired
    private LikeRepository likeRepository;

    @Autowired
    private FollowRepository followRepository;

    @Autowired
    private BlogRepository blogRepository;

    @Transactional
    public boolean likeArticle(Long articleId, String userId, String following) {
        // 記事作成者をフォローしていない場合はfalseを返します。
        if (!isFollowingAuthor(articleId, userId, following)) {
            return false;
        }

        // すでに「いいね」した場合もfalseを返す
        if (likeRepository.existsByarticleIdAndUserId(articleId, userId)) {
            return false;
        }

        // その記事に対する「いいね」を保存する
        Like like = new Like(articleId, userId);
        like.setArticleId(articleId);
        like.setUserId(userId);
        likeRepository.save(like);
        return true;
    }


    public int getLikeCount(Long articleId) {
        return likeRepository.countByarticleId(articleId);
    }


    //記録者をフォローしているか確認するメソッド
    public boolean isFollowingAuthor(Long articleId, String userId, String following) {

        // 記録IDで記録者を検索して設定する
        String authorId = getAuthorByarticleId(articleId);

        // 記録者のIDが同じなら
        if (userId.equals(authorId)) {

            // フォローユーザーと記録者のIDを受け取る
            String followed = authorId;
            return followRepository.existsByFollowingAndFollowed(following, followed);
        }
        return false;
    }

    public String getAuthorByarticleId(Long articleId) {
        // Spring Data JPAによって自動的に実装されたメソッドを使用して、記事IDによって記事の作成者を見つける
        Optional<Article> articleOptional = blogRepository.findById(articleId);

        if (articleOptional.isPresent()) {
            Article article = articleOptional.get();
            return article.getAuthor();
        } else {
            return "Article not found";
        }
    }

    @Transactional
    public boolean cancelLikeArticle(Long articleId, String userId, String following) {
        if (likeRepository.existsByarticleIdAndUserId(articleId, userId) && isFollowingAuthor(articleId, userId, following)) {
            likeRepository.deleteByarticleIdAndUserId(articleId, userId);
            return true;
        }
        return false;
    }

    //記事IDとユーザーIDに対して「いいね!」が既に登録されていることを確認する
    public boolean isLiked(Long articleId, String userId, String following) {
        if (isFollowingAuthor(articleId, userId, following)) {
            return likeRepository.existsByarticleIdAndUserId(articleId, userId);
        }
        return false;
    }
}


public boolean likeArticle(Long articleId, String userId, String following)

このメソッドはいいねを追加する機能を実行します。まず、isFollowingAuthorメソッドを呼び出して現在のユーザーが記事作成者をフォローしているか確認します。フォローしていない場合はfalseを返します。
次に、すでにその記事にいいねを押したかどうかを確認します。すでに押した場合はfalseを返します。
上の二つの条件を全て満たしたら、Likeエンティティを生成してその記事にいいねを追加してtrueを返します。

public int getLikeCount(Long articleId)

このメソッドは特定記事のいいね数を照会して返します。likeRepository.countByarticleId(articleId)を呼び出して該当記事のいいね数を照会します。


public boolean isFollowingAuthor(Long articleId, String userId, String following)

このメソッドは現在のユーザーが記事作成者をフォローしているかどうかを確認します。
まず、getAuthorByarticleIdメソッドを呼び出して該当記事の作成者を照会します。
もし記事作成者のIDと現在のユーザーのIDが同じなら、followRepository.existsByFollowingAndFollowed(following, followed)を呼び出してフォローしているかどうかを確認します。

public String getAuthorByarticleId(Long articleId)

このメソッドは特定の記事の作成者IDを照会して返します。blogRepository.findById(articleId)を呼び出して該当記事を照会して、作成者のIDを返します。

public boolean cancelLikeArticle(Long articleId, String userId, String following)

このメソッドはいいねをキャンセルする機能を実行します。
まず、likeRepository.existsByarticleIdAndUserId(articleId, userId)で現在のユーザーがその記事にいいねを押したかどうかを確認し、isFollowingAuthorで現在のユーザーが記事作成者をフォローしているかを確認します。
両方の条件を満たしたら、likeRepository.deleteByarticleIdAndUserId(articleId, userId)を呼び出して該当記事のいいねをキャンセルします。

public boolean isLiked(Long articleId, String userId, String following)

このメソッドは特定ユーザーが特定記事にいいねを押したかどうかを確認します。
まず、isFollowingAuthorで現在のユーザーが記事作成者をフォローしているかどうかを確認し、likeRepository.existsByarticleIdAndUserId(articleId, userId)でいいねを押したかどうかを確認します。
実際には、HTML画面でいいねボタンの状態を設定する目的で使用されます。


3.5 Controller - LikeController.java

@RestController
public class LikeController {

    @Autowired
    private LikeService likeService;


    @PostMapping("/likeArticle")
    public ResponseEntity<LikeResponse> likeArticle(@RequestBody LikeRequest request, Principal principal) {
        LikeResponse response = new LikeResponse();
        String current_userId = principal.getName();

        // 記事の作成者をフォローしているか確認
        if (likeService.isFollowingAuthor(request.getArticleId(), request.getUserId(), current_userId)) {
            if (likeService.likeArticle(request.getArticleId(), request.getUserId(), current_userId)) {
                response.setLikeCount(likeService.getLikeCount(request.getArticleId()));
            }
        } else {
            // 文章作成者をフォローしていない場合に対する処理
            response.setError("ユーザーをフォローしていません。");
        }

        return ResponseEntity.ok(response);
    }

    @DeleteMapping("/cancelLikeArticle")
    public LikeResponse cancelLikeArticle(@RequestBody LikeRequest request, Principal principal) {
        LikeResponse response = new LikeResponse();
        String current_userId = principal.getName();
        if (likeService.cancelLikeArticle(request.getArticleId(), request.getUserId(), current_userId)) {
            response.setLikeCount(likeService.getLikeCount(request.getArticleId()));
        }
        return response;
    }

}

@PostMapping("/likeArticle")
public ResponseEntity<LikeResponse> likeArticle(@RequestBody LikeRequest request, Principal principal)

このメソッドはHTTP POSTリクエストでいいねを追加する役割をします。
クライアントから受信したLikeRequestオブジェクトと現在のユーザーの情報(Principal principal)を受け取ります。
likeService.isFollowingAuthorを呼び出して記事作成者をフォローしてるか確認します。
もしフォローしているなら、likeService.likeArticleを呼び出してその記事にいいねを追加して、追加されたいいねの数をLikeResponseに設定します。
フォローしていない場合は、エラーメッセージをLikeResponseに設定して返します。

@DeleteMapping("/cancelLikeArticle")
public LikeResponse cancelLikeArticle(@RequestBody LikeRequest request, Principal principal)

このメソッドはHTTP DELETEリクエストでLikeをキャンセルする役割をします。
クライアントから受け取ったLikeRequestオブジェクトと現在のユーザーの情報(Principal principal)を受け取ります。
likeService.cancelLikeArticleを呼び出して該当記事のいいねをキャンセルして、キャンセルされた後のいいね数をLikeResponseに設定します。


3.6.1 DTO - LikeRequest.java

@Data
public class LikeRequest {
    private Long articleId;
    private String userId;
 
}

クラスLikeRequestはいいねをリクエストするデータを入れるDTO(Data Transfer Object)です。DTOはデータ転送のためのオブジェクトで、ここではクライアントからサーバーへLikeをリクエストする時に送信されるデータを入れます。

3.6.2 DTO - LikeResponse.java

@Data
public class LikeResponse {
    private int likeCount;
    private String Error;

}

クラスLikeResponseはいいねの処理結果を入れるDTOです。DTOはデータ転送のためのオブジェクトです。

 ある者「一体、LikeRequestとLikeResponse DTOを使用する理由は何ですか」という疑問ができそうかもしれないですね。

DTOはクライアントとサーバー間のデータ転送過程を効率的に管理し、データの構造と意図を明確に表現するために使用されます。

1) クライアント-サーバー間のデータ転送の明確化
クライアントは正確なフィールド名とデータ型を知っているので、データ転送の過程でエラーを防止し、デバッグを容易にします。

2) 不要なデータ転送防止
DTOを使用して必要なデータだけを転送することができます。例えば、LikeRequest DTOでは、いいねの登録またはキャンセルに必要な「articleId」と「userId」のみを含んでいます。 したがって、不要なデータが送信されるのを防止してネットワークトラフィックを減らし、パフォーマンスを改善することができます。

3) データ変換の容易性
DTOを通じて、異なるデータ構造間の変換を容易にすることができます。クライアントとサーバー間のデータ構造が異なる場合や、データベースエンティティとクライアント間のデータ構造が異なる場合、DTOを使用して変換作業を行うことができます。

4) データ隠蔽とセキュリティ
デエンティティクラスはデータベースと密接に関連しているため、不要なデータを外部に公開してはいけません。DTOを使用すると、エンティティの重要な詳細を隠し、必要な情報だけを伝達することができます。

3.7 フロントエンドのHttpRequest - like.js

function toggleLike(likeButton) {
    // いいねボタンから記事IDと登録者IDを抽出します。
    const articleId = parseInt(likeButton.dataset.itemId, 10);
    const author = likeButton.dataset.author;

    // サーバーから受け取った応答データを処理する非同期関数(async - await)
    async function success(response) {
        try {
           // サーバーから返されたJSON形式の応答データを解析 
            const data = await response.json();

            // 応答データにエラーメッセージがある場合のみ処理
            if (data.error) {
                
                alert(data.error);
                return;
            }
       // いいね数とボタンの状態を更新
            const likeCount = document.getElementById(`likeCount-${articleId}`);
            if (likeCount) {
                likeCount.textContent = data.likeCount;
                likeButton.disabled = false;

                const isLiked = isButtonLiked(likeButton);
                updateButtonState(likeButton, isLiked);
            } else {
                console.error(`Element 'likeCount-${articleId}' not found.`);
            }
        } catch (error) {
            console.error('Error parsing JSON:', error);
        }
    }

    // 失敗時の処理
    function fail(response) {
        alert('いいねの処理中に問題が発生しました.');
    }

    // ボタンの状態に応じて「いいね」の登録/削除を分岐させる
    const isLiked = isButtonLiked(likeButton);
    const method = isLiked ? 'DELETE' : 'POST';
    const url = isLiked ? '/cancelLikeArticle' : '/likeArticle';
    likeHttpRequest(method, url, JSON.stringify({ articleId: articleId, userId: author }), success, fail);
}



function isButtonLiked(likeButton) {
    return likeButton.textContent === '♡' || likeButton.querySelector('.liked');
}

function updateButtonState(likeButton, isLiked) {
    likeButton.textContent = isLiked ? '♥' : '♡';
}

toggleLike(likeButton)

この関数はいいねボタンをトグルする役割をします。
likeButtonパラメータはクリックされたいいねボタンの要素です。
ボタンのdata属性からarticleIdとauthor情報を抽出します。
いいねリクエストの成功と失敗によってsuccessとfail関数を呼び出します。

success(response)

この関数は「いいね」リクエストが正常に処理された時に呼び出されます。
サーバーから返された応答データをJSONで解析して処理します。
エラーメッセージがある場合、警告ウィンドウを表示して終了します。
いいねの数を表示する要素(likeCount)を更新し、ボタンの状態を更新します。

この関数ではやはり非同期関数が非常に重要です。「async」と「await」の組み合わせに注目する必要があります。
async function success(response)
この関数は非同期で実行され、サーバーの応答データを処理します。

const data = await response.json();
サーバーから受け取った応答(response)をJSON形式でパースします。 awaitキーワードを使って非同期でパースを待ち、データをdata変数に保存します。

非同期的に処理するコードの中で「async/await」と「$.ajax」がありますけど、最近は「async/await」の方がもっとつかわれるそうです。

「async/await」のメリット?
・try/catch を使ってエラー処理が容易で、エラー発生時にも例外処理が簡単です。
・async/awaitを使う関数はPromise(JavaScriptで非同期作業を処理して管理するためのオブジェクト)を返すので、他の非同期関数と組み合わせて使うことができます。
・ES2017(ECMAScript 8)から標準で採用された機能で、より直感的で読みやすく、エラー処理が簡単です。 

これが私がいいねの数を非同期的に表示するために「async/await」を使った理由です。

「async/await」のデメリット?
・古いブラウザや環境ではサポートが制限される。
・複数の非同期タスクを同時に実行する場合、並列実行が難しいです。

$.ajax (jQuery Ajax)のメリット?
・古いブラウザからサポートされ、ブラウザの互換性が良いです。
・複数の非同期タスクを並列に実行するときに便利な$.whenなどのメソッドを提供します。

$.ajax のデメリット?
・jQueryに依存しています。
・コールバックの形で非同期コードを書くことになり、可読性が低くなる可能性があります。
・コールバック地獄(callback hell)の問題が発生する可能性があります。

fail(response)

この関数は、いいねリクエストの処理中に問題が発生した時に呼び出されます。
警告ウィンドウを表示してユーザーに問題が発生したことを知らせます。

isButtonLiked(likeButton) 

この関数は、現在のボタンがいいね状態かどうかを確認する役割をします。
ボタンのテキストコンテンツが'♡'か.likedクラスを持っているかどうかを確認して結果を返します。

updateButtonState(likeButton, isLiked)

この関数はボタンの状態を更新します。
isLikedの値に応じてボタンのテキストコンテンツを変更して、現在のいいねの状態を表示します。


4. テスト(08.14-08.15)


4.1 単体テスト

@BeforeEach
    public void mockMvcSetUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .build();
        likeRepository.deleteAll();
    }

    @BeforeEach
    void setSecurityContext() {
        userRepository.deleteAll();
        user = userRepository.save(User.builder()
                .email("author1@gmail.com")
                .password("test")
                .build());
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()));
    }

    @DisplayName("likeArticle: いいね登録 成功")
    @Test
    public void likeArticle() throws Exception {
        // given
        final Long articleId = 1L;
        final String userId = "testuser@gmail.com";
        final String following = "author1@gmail.com";

        LikeRequest likeRequest = new LikeRequest();
        likeRequest.setArticleId(articleId);
        likeRequest.setUserId(userId);

        Principal principal = Mockito.mock(Principal.class);
        Mockito.when(principal.getName()).thenReturn(userId);

        // when
        ResultActions result = mockMvc.perform(post("/likeArticle")
                .contentType(MediaType.APPLICATION_JSON)
                .content(asJsonString(likeRequest))
                .principal(principal));

        // then
        result.andExpect(status().isOk());
    }

    @DisplayName("cancelLikeArticle: いいねキャンセル 成功")
    @Test
    public void cancelLikeArticle() throws Exception {
        // given
        final Long articleId = 1L;
        final String userId = "testuser@gmail.com";
        final String following = "author1@gmail.com";

        LikeRequest likeRequest = new LikeRequest();
        likeRequest.setArticleId(articleId);
        likeRequest.setUserId(userId);

        Principal principal = Mockito.mock(Principal.class);
        Mockito.when(principal.getName()).thenReturn(userId);

        // まず いいね 登録
        mockMvc.perform(post("/likeArticle")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(asJsonString(likeRequest))
                        .principal(principal))
                .andExpect(status().isOk());

        // when
        ResultActions result = mockMvc.perform(delete("/cancelLikeArticle")
                .contentType(MediaType.APPLICATION_JSON)
                .content(asJsonString(likeRequest))
                .principal(principal));

        // then
        result.andExpect(status().isOk());

    }


    private String asJsonString(Object obj) throws Exception {
        return objectMapper.writeValueAsString(obj);
    }

mockMvcSetUp()

テストごとにMockMvcインスタンスを設定します。
webApplicationContextを設定してMockMvcを生成します。
likeRepositoryの全てのデータを削除します。

setSecurityContext()

テストごとにセキュリティコンテキストを設定します。
userRepositoryのすべてのデータを削除した後、新しいUserオブジェクトを生成して保存します。
そのユーザーでセキュリティコンテキストを設定します。

likeArticle()

likeArticleメソッドをテストします。

以後はテストシナリオを想定します。
articleId, userId, followingを持ってるLikeRequestオブジェクトを生成します。
Principalオブジェクトをモッキングして現在のユーザーのIDを返すように設定します。
postリクエストで/likeArticleエンドポイントにリクエストを送信します。
期待する応答状態コードはisOk() (200 OK)です。

cancelLikeArticle()

cancelLikeArticleメソッドをテストします。

以後はテストシナリオを想定します。
まず、いいねを追加した後、キャンセルをテストします。
テストシナリオを仮定します: articleId, userId, followingを持っているLikeRequestオブジェクトを生成します。
Principalオブジェクトをモッキングして現在のユーザーのIDを返すように設定します。
まず、postリクエストで/likeArticleエンドポイントにいいねリクエストを送ります。
その後、deleteリクエストで/cancelLikeArticleエンドポイントにキャンセルリクエストを送ります。
期待する応答ステータスコードはisOk() (200 OK) です。

asJsonString(Object obj)

オブジェクトをJSON文字列に変換するメソッドです。
objectMapperを使ってオブジェクトをJSON文字列に変換して返します。

やった、無事に単体テストを成功!


4.2 結合テスト

① ハートボタンを押すと、いいね数が1だけ増加します。
② 真ん中が空洞のハート型になります。
③ページを更新しても、以前に保存されたいいねの数がそのまま反映されることが確認できます。
④このボタンをもう一度押すと、いいね数が1つ減ります。
⑤ user6@gmail.com ユーザーアンフォロする。
⑥ user6@gmail.com ユーザーの履歴にいいねボタンを押しても
いいね数が増加せず、「ユーザーをフォローしていません」
というメッセージが表示されることが確認できます。


5. 形状管理 - Git/GitHub


5.1 Git-Commit/ Github-Push

git add .
git commit -m 'feat : Add like'
① ローカルGitにコミットします。
git branch -M main
git push origin main
② コミットしてメインブランチに変更し、
リモートリポジトリのメインブランチにプッシュします。
③ GitHubで自分のリポジトリに
コミットがうまくプッシュされましたね!


5.2 コミットメッセージの種類

コミットメッセージはプロジェクトの更新履歴をよく表し、コードの変更内容を分かりやすく伝える必要があります。いくつかのコミットメッセージを紹介します。

「feat」: 新機能追加
例: "feat: ユーザー登録機能追加"


「fix」: バグ修正
例: "fix: ログインエラー修正"


「docs」: ドキュメント関連の変更
例: "docs: APIドキュメント更新"

「style」: コードスタイル変更(スペース、フォーマットなど)
例: "style: コードソートやコメント追加"

「refactor」: コードのリファクタリング(機能変更なし)
例: "refactor: 重複コード関数でリファクタリング"

「chore」: ビルド、ツール関連の変更
例: "chore: ビルドスクリプト更新"

「test」: テストコードの追加や修正
例: "test: ユーザー登録テスト追加"

「perf」: パフォーマンス改善関連の変更
例: "perf: データベースクエリの最適化"

「revert」: 前のコミットに戻す
例: "revert: 前のコミットを復旧する"

「merge」: ブランチのマージ
例: "merge: feature/issue-3 ブランチをマージする"

私の場合は「feat」、「fix」、「refactor」だけ使ってみました。 これから色んなコミットメッセージを試してみたいですね!!

6.まとめ


「いいね」機能の設計からバックエンドとフロントエンドの実装、そしてテストまで8月7日から8月15日まで7日かかりました。 計画上は8月14日までに終わらせようとしましたが、フォロー機能みたいに予定より一日遅れてしまいました!

それにしても、 DTOによるエンティティ情報の隠蔽とデータ転送の明確化、非同期処理に関する理解を明確にすることで、以前よりさらに開発に自信が持てるようになりました!

実はフロントエンドの領域は少し弱いので、async/awaitで出てきたpromiseのような概念をもっと上手に使えるように勉強しないといけませんね!

今まで実装した「いいね」機能により、早起きのためのモチベーションをより強く得ることができそうです! それでは、8月23日までに記録投稿の際、画像アプロードができるように機能をアップグレードしてみます~!頑張ります!!!


エンジニアファーストの会社 株式会社CRE-CO
ソンさん


いいなと思ったら応援しよう!