1. 概述
在本文中,我們將繼續推進我們的小型案例研究應用程序,通過對現有功能的少量但有用的改進來實現小而有用的改進。
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);
}請注意我們是如何使用自定義標題來將分頁信息傳遞給客户端的。 還有其他一些更標準的方式可以實現這一點,我們稍後可能會探索它們。
不過,這個實現方式已經足夠簡單——我們有一個簡單的生成分頁信息的辦法。
public PagingInfo generatePagingInfo(int page, int size) {
long total = postRepository.countByUser(userService.getCurrentUser());
return new PagingInfo(page, size, total);
}以及 本身:
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 交互,並處理 jQuery DataTables 參數:
<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 時觸發事件:
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. 事件和監聽器
事件實現相當簡單:
The event implementation is pretty straightforward:
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;
}
}以及監聽器:
@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();
}
}4. 使用總投票數
接下來,我們將簡化重提交選項,不再使用投票比例(由於其難以理解)——現在我們使用總投票數。
我們可以使用帖子得分和投票比例計算總投票數:
- 得分 = 投票數 - 投降數
- 總投票數 = 投票數 + 投降數
- 投票比例 = 投票數 / 總投票數
因此:
總投票數 = Math.round(得分 / ((2 * 投票比例) - 1))
首先,我們將修改我們的得分邏輯,以計算並跟蹤總投票數:
public PostScores getPostScores(Post post) {
...
float ratio = node.get("upvote_ratio").floatValue();
postScore.setTotalVotes(Math.round(postScore.getScore() / ((2 * ratio) - 1)));
...
}當然,我們還會利用它來檢查帖子是否被認為失敗:
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 字段,不再使用。
5. 驗證重提交選項
最後,我們將為用户添加驗證,以處理複雜的重提交選項:
5.1. 計劃發佈服務 (ScheduledPost Service)
以下是簡單的 checkIfValidResubmitOptions() 方法:
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;
}我們將在安排新帖文時充分利用驗證功能:
public Post schedulePost(boolean isSuperUser, Post post, boolean resubmitOptionsActivated)
throws ParseException {
if (resubmitOptionsActivated && !checkIfValidResubmitOptions(post)) {
throw new InvalidResubmitOptionsException("Invalid Resubmit Options");
}
...
}請注意,如果啓用重試邏輯,則以下字段必須具有非零值:
- 重試次數
- 時間間隔
- 最低得分要求
5.2. 異常處理
最後 – 在出現無效輸入的情況下,InvalidResubmitOptionsException 將被處理在我們的主要錯誤處理邏輯中:
@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. 測試重提交選項
最後,讓我們現在測試我們的重提交選項,我們將測試激活和停用條件:
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 上安排新文章,通過進入應用、完成工作並退出。
正在朝着正確的方向發展。