Redditアプリケーションの3回目の改良

1概要

この記事では、リンクを移動し続けます。/case-study-a-reddit-app-with-spring[私たちの小さなケーススタディアプリ]は、既存の機能に小さいながらも便利な改善を実装することで前進します。

2より良いテーブル

jQuery DataTablesプラグインを使用して、アプリが以前使用していた古い基本的なテーブルを置き換えましょう。

** 2.1. リポジトリとサービスの投稿

**

まず、ユーザーの予定投稿数をカウントする方法を追加します。もちろん、Spring Dataの構文を利用します。

public interface PostRepository extends JpaRepository<Post, Long> {
    ...
    Long countByUser(User user);
}

次に、 サービス層の実装 を簡単に見てみましょう。

@Override
public List<SimplePostDto> getPostsList(int page, int size, String sortDir, String sort) {
    PageRequest pageReq = new PageRequest(page, size, Sort.Direction.fromString(sortDir), sort);
    Page<Post> posts = postRepository.findByUser(userService.getCurrentUser(), pageReq);
    return constructDataAccordingToUserTimezone(posts.getContent());
}

ユーザーのタイムゾーンに基づいて日付を変換します。

private List<SimplePostDto> constructDataAccordingToUserTimezone(List<Post> posts) {
    String timeZone = userService.getCurrentUser().getPreference().getTimezone();
    return posts.stream().map(post -> new SimplePostDto(
      post, convertToUserTomeZone(post.getSubmissionDate(), timeZone)))
      .collect(Collectors.toList());
}
private String convertToUserTomeZone(Date date, String timeZone) {
    dateFormat.setTimeZone(TimeZone.getTimeZone(timeZone));
    return dateFormat.format(date);
}

** 2.2. ページ付け付きAPI

**

次に、この操作を、APIを介して、完全なページ区切りとソートで公開します。

@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public List<SimplePost> getScheduledPosts(
  @RequestParam(value = "page", required = false, defaultValue = "0") int page,
  @RequestParam(value = "size", required = false, defaultValue = "10") int size,
  @RequestParam(value = "sortDir", required = false, defaultValue = "asc") String sortDir,
  @RequestParam(value = "sort", required = false, defaultValue = "title") String sort,
  HttpServletResponse response) {
    response.addHeader("PAGING__INFO",
      scheduledPostService.generatePagingInfo(page, size).toString());
    return scheduledPostService.getPostsList(page, size, sortDir, sort);
}

ページ付け情報をクライアントに渡すためにカスタムヘッダーを使用していることに注意してください。もう少し標準的なリンクがあります:/rest-api-pagination-in-spring[これを行う方法] - あとで探るかもしれない方法。

しかしながら、この実装は単に十分です - ページング情報を生成する簡単な方法があります。

public PagingInfo generatePagingInfo(int page, int size) {
    long total = postRepository.countByUser(userService.getCurrentUser());
    return new PagingInfo(page, size, total);
}

そして PagingInfo それ自身:

public class PagingInfo {
    private long totalNoRecords;
    private int totalNoPages;
    private String uriToNextPage;
    private String uriToPrevPage;

    public PagingInfo(int page, int size, long totalNoRecords) {
        this.totalNoRecords = totalNoRecords;
        this.totalNoPages = Math.round(totalNoRecords/size);
        if (page > 0) {
            this.uriToPrevPage = "page=" + (page - 1) + "&size=" + size;
        }
        if (page < this.totalNoPages) {
            this.uriToNextPage = "page=" + (page + 1) + "&size=" + size;
        }
    }
}

2.3. フロントエンド

最後に、単純なフロントエンドはカスタムJSメソッドを使用してAPIと対話し、https://www.datatables.net/manual/server-side[jQuery DataTableパラメーター]を処理します。

<table>
<thead><tr>
<th>Post title</th><th>Submission Date</th><th>Status</th>
<th>Resubmit Attempts left</th><th>Actions</th>
</tr></thead>
</table>

<script>
$(document).ready(function() {
    $('table').dataTable( {
        "processing": true,
        "searching":false,
        "columnDefs":[            { "name": "title", "targets": 0 },
            { "name": "submissionDate", "targets": 1 },
            { "name": "submissionResponse", "targets": 2 },
            { "name": "noOfAttempts", "targets": 3 }],
        "columns":[            { "data": "title" },
            { "data": "submissionDate" },
            { "data": "submissionResponse" },
            { "data": "noOfAttempts" }],
        "serverSide": true,
        "ajax": function(data, callback, settings) {
            $.get('api/scheduledPosts', {
              size: data.length,
              page: (data.start/data.length),
              sortDir: data.order[0].dir,
              sort: data.columns[data.order[0].column].name
              }, function(res,textStatus, request) {
                var pagingInfo = request.getResponseHeader('PAGING__INFO');
                var total = pagingInfo.split(",")[0].split("=")[1];
                callback({recordsTotal: total, recordsFiltered: total,data: res});
              });
          }
    } );
} );
</script>

2.4. ページング用のAPIテスト

現在公開されているAPIを使えば、ページングメカニズムの基本が期待どおりに機能することを確認するために、 簡単なAPIテスト をいくつか書くことができます。

@Test
public void givenMoreThanOnePage__whenGettingUserScheduledPosts__thenNextPageExist()
  throws ParseException, IOException {
    createPost();
    createPost();
    createPost();

    Response response = givenAuth().
      params("page", 0, "size", 2).get(urlPrefix + "/api/scheduledPosts");

    assertEquals(200, response.statusCode());
    assertTrue(response.as(List.class).size() > 0);

    String pagingInfo = response.getHeader("PAGING__INFO");
    long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
    String uriToNextPage = pagingInfo.split(",")[2].replace("uriToNextPage=", "").trim();

    assertTrue(totalNoRecords > 2);
    assertEquals(uriToNextPage, "page=1&size=2");
}

@Test
public void givenMoreThanOnePage__whenGettingUserScheduledPostsForSecondPage__thenCorrect()
  throws ParseException, IOException {
    createPost();
    createPost();
    createPost();

    Response response = givenAuth().
      params("page", 1, "size", 2).get(urlPrefix + "/api/scheduledPosts");

    assertEquals(200, response.statusCode());
    assertTrue(response.as(List.class).size() > 0);

    String pagingInfo = response.getHeader("PAGING__INFO");
    long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
    String uriToPrevPage = pagingInfo.split(",")[3].replace("uriToPrevPage=", "").trim();

    assertTrue(totalNoRecords > 2);
    assertEquals(uriToPrevPage, "page=0&size=2");
}

3メール通知

次に、基本的な電子メール通知フローを作成します - スケジュールされた投稿が送信されているときにユーザーが電子メールを受け取る場合

3.1. メール設定

まず、メールの設定をしましょう。

@Bean
public JavaMailSenderImpl javaMailSenderImpl() {
    JavaMailSenderImpl mailSenderImpl = new JavaMailSenderImpl();
    mailSenderImpl.setHost(env.getProperty("smtp.host"));
    mailSenderImpl.setPort(env.getProperty("smtp.port", Integer.class));
    mailSenderImpl.setProtocol(env.getProperty("smtp.protocol"));
    mailSenderImpl.setUsername(env.getProperty("smtp.username"));
    mailSenderImpl.setPassword(env.getProperty("smtp.password"));
    Properties javaMailProps = new Properties();
    javaMailProps.put("mail.smtp.auth", true);
    javaMailProps.put("mail.smtp.starttls.enable", true);
    mailSenderImpl.setJavaMailProperties(javaMailProps);
    return mailSenderImpl;
}

SMTPを動作させるために必要なプロパティと共に。

smtp.host=email-smtp.us-east-1.amazonaws.com
smtp.port=465
smtp.protocol=smtps
smtp.username=example
smtp.password=[email protected]----

====  **  3.2. 投稿が公開されたときにイベントを発生させる

**

スケジュールされた投稿がRedditに正常に公開されたときに、イベントを発生させます。

[source,java,gutter:,true]

private void updatePostFromResponse(JsonNode node, Post post) { JsonNode errorNode = node.get("json").get("errors").get(0); if (errorNode == null) { …​ String email = post.getUser().getPreference().getEmail(); eventPublisher.publishEvent(new OnPostSubmittedEvent(post, email)); } …​ }

====  **  3.3. イベントとリスナー**

イベントの実装はかなり簡単です。

[source,java,gutter:,true]

public class OnPostSubmittedEvent extends ApplicationEvent { private Post post; private String email;

    public OnPostSubmittedEvent(Post post, String email) {
        super(post);
        this.post = post;
        this.email = email;
    }
}
そしてリスナー:

[source,java,gutter:,true]

@Component public class SubmissionListner implements ApplicationListener<OnPostSubmittedEvent> { @Autowired private JavaMailSender mailSender;

@Autowired
private Environment env;
@Override
public void onApplicationEvent(OnPostSubmittedEvent event) {
    SimpleMailMessage email = constructEmailMessage(event);
    mailSender.send(email);
}
private SimpleMailMessage constructEmailMessage(OnPostSubmittedEvent event) {
    String recipientAddress = event.getEmail();
    String subject = "Your scheduled post submitted";
    SimpleMailMessage email = new SimpleMailMessage();
    email.setTo(recipientAddress);
    email.setSubject(subject);
    email.setText(constructMailContent(event.getPost()));
    email.setFrom(env.getProperty("support.email"));
    return email;
}
    private String constructMailContent(Post post) {
        return "Your post " + post.getTitle() + " is submitted.\n" +
          "http://www.reddit.com/r/" + post.getSubreddit() +
          "/comments/" + post.getRedditID();
    }
}
[[votes]]

===  **  4合計投票数を使用する**

次に、再投票オプションを簡素化するための作業をいくつか行います。これは、投票率を使用するのではなく(理解するのが難しかった)、** 合計投票数で作業しています** 。

投稿スコアと投票率を使用して総投票数を計算できます。

** スコア=高得点 - 低得点

** 総投票数=プラス投票+マイナス投票

** 投票率=投票率/総投票数

など:

__総得票数= Math.round(得点/((2 ** 上昇率) -  1))__

まず、スコアのロジックを変更して、この合計投票数を計算して追跡します。

[source,java,gutter:,true]

public PostScores getPostScores(Post post) { …​

float ratio = node.get("upvote__ratio").floatValue();
postScore.setTotalVotes(Math.round(postScore.getScore()/((2 **  ratio) - 1)));
    ...
}
もちろん、投稿が失敗したと見なされるかどうかをチェックするときにも使用します。

[source,java,gutter:,true]

private boolean didPostGoalFail(Post post) { PostScores postScores = getPostScores(post); int totalVotes = postScores.getTotalVotes(); …​ return (score < post.getMinScoreRequired( || (totalVotes < post.getMinTotalVotes())) && !noOfComments > 0) && post.isKeepIfHasComments(); }

最後に、もちろん、古い__ratio__フィールドは使用しないようにします。

[[resubmit]]

===  **  5再送信オプションの検証**

最後に、複雑な再送信オプションに検証を追加してユーザーを支援します。

====  **  5.1.  __予定郵便__サービス**

これは単純な__checkIfValidResubmitOptions()__メソッドです。

[source,java,gutter:,true]

private boolean checkIfValidResubmitOptions(Post post) { if (checkIfAllNonZero( post.getNoOfAttempts(), post.getTimeInterval(), post.getMinScoreRequired())) { return true; } else { return false; } } private boolean checkIfAllNonZero(int…​ args) { for (int tmp : args) { if (tmp == 0) { return false; } } return true; }

新しい投稿をスケジュールするときに、この検証を有効に活用します。

[source,java,gutter:,true]

public Post schedulePost(boolean isSuperUser, Post post, boolean resubmitOptionsActivated) throws ParseException { if (resubmitOptionsActivated && !checkIfValidResubmitOptions(post)) { throw new InvalidResubmitOptionsException("Invalid Resubmit Options"); } …​ }

再送信ロジックがオンの場合、次のフィールドにはゼロ以外の値が必要です。

** 試行回数

**  時間間隔

** 最低スコアが必要

====  **  5.2. 例外処理**

最後に - 無効な入力の場合、__InvalidResubmitOptionsException__がメインのエラー処理ロジックで処理されます。

[source,java,gutter:,true]

@ExceptionHandler({ InvalidResubmitOptionsException.class }) public ResponseEntity<Object> handleInvalidResubmitOptions (RuntimeException ex, WebRequest request) {

    logger.error("400 Status Code", ex);
    String bodyOfResponse = ex.getLocalizedMessage();
    return new ResponseEntity<Object>(
      bodyOfResponse, new HttpHeaders(), HttpStatus.BAD__REQUEST);
}
====  **  5.3. 再送信オプションのテスト**

最後に、再送信オプションをテストしましょう。有効化条件と無効化条件の両方をテストします。

[source,java,gutter:,true]

public class ResubmitOptionsLiveTest extends AbstractLiveTest { private static final String date = "2016-01-01 00:00";

@Test
public void
  givenResubmitOptionsDeactivated__whenSchedulingANewPost__thenCreated()
  throws ParseException, IOException {
    Post post = createPost();
Response response = withRequestBody(givenAuth(), post)
  .queryParams("resubmitOptionsActivated", false)
  .post(urlPrefix + "/api/scheduledPosts");
    assertEquals(201, response.statusCode());
    Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
    assertEquals(result.getUrl(), post.getUrl());
}
@Test
public void
  givenResubmitOptionsActivated__whenSchedulingANewPostWithZeroAttempts__thenInvalid()
  throws ParseException, IOException {
    Post post = createPost();
    post.setNoOfAttempts(0);
    post.setMinScoreRequired(5);
    post.setTimeInterval(60);
Response response = withRequestBody(givenAuth(), post)
  .queryParams("resubmitOptionsActivated", true)
  .post(urlPrefix + "/api/scheduledPosts");
    assertEquals(400, response.statusCode());
    assertTrue(response.asString().contains("Invalid Resubmit Options"));
}
@Test
public void
  givenResubmitOptionsActivated__whenSchedulingANewPostWithZeroMinScore__thenInvalid()
  throws ParseException, IOException {
    Post post = createPost();
    post.setMinScoreRequired(0);
    post.setNoOfAttempts(3);
    post.setTimeInterval(60);
Response response = withRequestBody(givenAuth(), post)
  .queryParams"resubmitOptionsActivated", true)
  .post(urlPrefix + "/api/scheduledPosts");
    assertEquals(400, response.statusCode());
    assertTrue(response.asString().contains("Invalid Resubmit Options"));
}
@Test
public void
  givenResubmitOptionsActivated__whenSchedulingANewPostWithZeroTimeInterval__thenInvalid()
  throws ParseException, IOException {
    Post post = createPost();
    post.setTimeInterval(0);
    post.setMinScoreRequired(5);
    post.setNoOfAttempts(3);
Response response = withRequestBody(givenAuth(), post)
  .queryParams("resubmitOptionsActivated", true)
  .post(urlPrefix + "/api/scheduledPosts");
    assertEquals(400, response.statusCode());
    assertTrue(response.asString().contains("Invalid Resubmit Options"));
}
@Test
public void
  givenResubmitOptionsActivated__whenSchedulingNewPostWithValidResubmitOptions__thenCreated()
  throws ParseException, IOException {
    Post post = createPost();
    post.setMinScoreRequired(5);
    post.setNoOfAttempts(3);
    post.setTimeInterval(60);
Response response = withRequestBody(givenAuth(), post)
  .queryParams("resubmitOptionsActivated", true)
  .post(urlPrefix + "/api/scheduledPosts");
    assertEquals(201, response.statusCode());
    Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
    assertEquals(result.getUrl(), post.getUrl());
}
    private Post createPost() throws ParseException {
        Post post = new Post();
        post.setTitle(randomAlphabetic(6));
        post.setUrl("test.com");
        post.setSubreddit(randomAlphabetic(6));
        post.setSubmissionDate(dateFormat.parse(date));
        return post;
    }
}
===  **  6. 結論**

今回の記事では、ケーススタディアプリを正しい方向に移動させる** 使いやすさといういくつかの改善を行いました。

Reddit Schedulerアプリの全体的なアイデアは、アプリに入って仕事をして外に出ることによって、ユーザーが新しい記事を素早くRedditにスケジュールできるようにすることです。

そこに着いています。