概要
この記事は初めてWEBアプリを1から作ることに挑戦した時の記録です。
理解が及んでいない部分もあるので技術解説はできておりませんが、設計から実装までの流れやソースコードを掲載しておりますので他の初学者の参考に少しでもなればと思います。
補足:ポモドーロタイマーについて
時間管理術としてポモドーロテクニックというものがあります。
タスクを効率よくこなすために時間を区切って作業するという考えで、これを実践するためのツールとしてポモドーロタイマーがあります。
スマホアプリでも色々ありますが今回WEBアプリとしてこちらのPomodoro Trackerを参考にしています。
完成したもの
作業の開始時刻を記録し、50分後にアラートを表示して作業終了時刻を記録するWEBアプリケーション。
環境
- SpringToolSuite4
- Java11(OpenJDK)
- PostgreSQL11
成果物
.
├── src
│ ├── main
│ │ ├── java
│ │ │ └── jp
│ │ │ └── co
│ │ │ └── anyplus
│ │ │ ├── Entity
│ │ │ │ └── TasksEntity.java
│ │ │ ├── Repository
│ │ │ │ └── TasksRepository.java
│ │ │ ├── ServletInitializer.java
│ │ │ ├── TaskTrackerApplication.java
│ │ │ ├── controller
│ │ │ │ └── IndexController.java
│ │ │ ├── form
│ │ │ │ └── TaskForm.java
│ │ │ └── service
│ │ │ ├── TaskCancelService.java
│ │ │ └── TaskRegisterService.java
│ │ ├── resources
│ │ │ ├── application.properties
│ │ │ ├── static
│ │ │ │ ├── css
│ │ │ │ │ ├── bootstrap-grid.css
│ │ │ │ │ ├── bootstrap-grid.css.map
│ │ │ │ │ ├── bootstrap-grid.min.css
│ │ │ │ │ ├── bootstrap-grid.min.css.map
│ │ │ │ │ ├── bootstrap-reboot.css
│ │ │ │ │ ├── bootstrap-reboot.css.map
│ │ │ │ │ ├── bootstrap-reboot.min.css
│ │ │ │ │ ├── bootstrap-reboot.min.css.map
│ │ │ │ │ ├── bootstrap.css
│ │ │ │ │ ├── bootstrap.css.map
│ │ │ │ │ ├── bootstrap.min.css
│ │ │ │ │ ├── bootstrap.min.css.map
│ │ │ │ │ └── jQuery.countdownTimer.css
│ │ │ │ ├── images
│ │ │ │ └── js
│ │ │ │ ├── bootstrap.bundle.js
│ │ │ │ ├── bootstrap.bundle.js.map
│ │ │ │ ├── bootstrap.bundle.min.js
│ │ │ │ ├── bootstrap.bundle.min.js.map
│ │ │ │ ├── bootstrap.js
│ │ │ │ ├── bootstrap.js.map
│ │ │ │ ├── bootstrap.min.js
│ │ │ │ ├── bootstrap.min.js.map
│ │ │ │ ├── import.js
│ │ │ │ ├── jQuery.countdownTimer.js
│ │ │ │ ├── jQuery.countdownTimer.min.js
│ │ │ │ ├── jQuery.countdownTimer.min.js.map
│ │ │ │ ├── jquery-3.4.1.min.js
│ │ │ │ ├── localisation
│ │ │ │ └── view-index.js
│ │ │ └── templates
│ │ │ └── index.html
設計
機能はなるべくシンプルにして、とりあえず1日で作れる範囲のものという大前提で作りました。
機能設計
- タイマー表示機能。「50:00」から開始しカウントダウンを表示する。
- タイマー開始ボタン。タスク内容と開始時刻をDBに記録する。
- タイマー終了機能。「00:00」の時、または終了ボタン押下時に終了時刻をDBに記録し、アラートを表示する。
// 実績表示は今回見送りました。今後実装したいです。
シーケンス図は以下のとおりです。
テーブル設計
実施したタスクを格納するだけでいいのでテーブルは1つだけ作ります。
タスクテーブル
カラム | 説明 |
---|---|
task_id | シーケンス値 |
task_name | タスク名 |
task_category | 作業のカテゴリ。 |
task_starttime | 作業開始時刻 |
task_endtime | 作業終了時刻 |
userid | ユーザーID。ユーザー機能は無いのでsystem しか入らいない想定 |
DB
今回のWEBアプリ用のDBを用意し、テーブルは以下の通り作りました。
CREATE TABLE public.tasks (
task_id bigserial NOT NULL,
task_name varchar(200) NULL,
task_category varchar(200) NULL,
task_starttime timestamp NULL,
task_endtime timestamp NULL,
userid varchar(20) NULL
);
プロジェクトの作成
プロジェクトの作成をします。
今回はSpringスターター・プロジェクトから以下のように作りました。
上記で設定したMavenリポジトリは以下の通り。
- SpringBootDevTool:Javaソースの変更をトリガーに自動でコンパイルし再起動してくれる便利な開発ツール。
- SpringDataJpa:DB操作に便利なライブラリ。Java<->DB間の自動型変換などをしてくれる。
- PostgreSQL Driver:PostgreSQLへアクセスするためのドライバー
- Thymeleaf:テンプレートエンジン。属性値で記述した変数を自動で値に置き換える
- SpringWebStarter:WEBアプリケーションに必要な設定やライブラリの依存関係がすべてまとまった定義セット。
プロジェクト作成後、プロジェクト上で右クリック>実行>Maven Installを実施し、pom.xmlに定義されたリポジトリファイルをインストールします。
今回の手順ではSpringスターター・プロジェクトでpom.xmlを自動生成していますが、
バージョンの変更、ライブラリの追加を手動で行う際はpom.xmlを直接編集し、Maven InstallまたはMaven CleanとInstallを行うと良いようです。
Mavenリポジトリはこのサイトから探せます。
次にapplication.propertiesファイルを開きDBの接続設定をします。
spring.datasource.url=jdbc:postgresql://localhost:5432/[データベース名]
spring.datasource.username=[データベースのユーザー]
spring.datasource.password=[データベースのパスワード]
spring.datasource.driver-class-name=org.postgresql.Driver
問題なくプロジェクトが用意できたか確認するため、実行します。
(プロジェクト上で右クリック>実行>SpringBootアプリケーション)
以下メッセージがコンソールログに出力されていることを確認して次へ進みます。
o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): ー(略)ー
jp.co.anyplus.TaskTrackerApplication : Started [アプリケーション名] in [数値] seconds (JVM running for [数値])
画面の実装
ライブラリ
まず必要なライブラリを入れます。
JavaScriptの拡張としてjQuery、レイアウトはBootstrap、タイマーはCountdownTimerというものがありましたのでこちらを使用しました。
- jQuery3.4.1
- Bootstarp4.2.1
- CountdownTimer
CountdownTimer(https://github.com/harshen/jQuery-countdownTimer/)は
日付、時間のカウントアップやカウントダウンの機能が一通り揃っているjQueryライブラリ。
レイアウトの指定やStart・Stop一通りの機能が揃っており今回のアプリにうってつけでした。
html
今回作ったのは1画面のみです。
レイアウトはBootstrapで決めています。
タイマーは<span id="timerView">
で表示エリアを決め、CoundownTimerの部品の方に表示箇所を指定しています。
CoundownTimerのデフォルトCSSはちょっとイマイチだったので編集していますが、ここでは記載省略します。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" type="text/css" href="/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/css/jQuery.countdownTimer.css" />
<title>TaskTracker</title>
</head>
<body>
<main class="container">
<h1>Task Tracker</h1>
<p>タスクを記録します。</p>
<div class="container text-center">
<div id="taskMainContainer" class="jumbotron col-md-8 mx-auto">
<form id="TaskForm" th:object="${taskForm}">
<div class="row">
<div class="col display-4"><span id="timerView">50:00</span></div>
</div>
<div class="row mt-3 justify-content-md-center">
<div class="col-md-4 "><button id="startButton" type="button" class="btn btn-block btn-success" disabled>Start</button></div>
<div class="col-md-4 "><button id="stopButton" type="button" class="btn btn-block btn-danger" disabled>Stop</button></div>
</div>
<div class="row mt-3 justify-content-md-center">
<div class="col-md-4 p-1"><input th:field="*{category}" type="text" class="form-control" placeholder="カテゴリ"></div>
<div class="col-md-8 p-1"><input th:field="*{taskName}" type="text" class="form-control" placeholder="タスク"></div>
<div><input th:field="*{taskId}" type="text" class="form-control" hidden></div>
</div>
</form>
</div>
</div>
</main>
<script src="/js/import.js"></script>
<script src="/js/view-index.js"></script>
</body>
</html>
JavaScript
ライブラリのインポート用としてimport.js、画面処理を記述するためのview-index.jsを作りました。
/**
* import
**/
document.write('<script type="text/javascript" src="/js/jquery-3.4.1.min.js"></script>');
document.write('<script type="text/javascript" src="/js/bootstrap.bundle.min.js"></script>');
/*--------------------------------
* index.html用JS
--------------------------------*/
document.write('<script type="text/javascript" src="/js/jQuery.countdownTimer.js"></script>');
$(function(){
$("#timerView").countdowntimer({
minutes :50,
seconds :00,
displayFormat : "MS",
size : "xl",
timeUp : taskFinish
});
$("#timerView").countdowntimer("stop", "stop");
stopbtnOn();
});
/*--------------------------------
* タスクを開始
--------------------------------*/
$('#startButton').on('click', taskStart);
function taskStart(){
startbtnOn();
$("#timerView").countdowntimer("stop", "start");
var form = $('#TaskForm').serializeArray();
var formdata = {};
jQuery.each(form, function(i, e) {
formdata[e.name] = e.value;
});
$.ajax({
url:'/',
type:'POST',
contentType : 'application/json; charset=utf-8',
data: JSON.stringify(formdata)
}).done( (data) => {
console.log("success");
console.log(data);
$('#taskId').val(data);
}).fail( (data) => {
console.log("fail");
console.log(data);
});
}
/*--------------------------------
* タスクを停止
--------------------------------*/
$('#stopButton').on('click', taskStop);
function taskStop(){
stopbtnOn();
$("#timerView").countdowntimer("stop", "stop");
var form = $('#TaskForm').serializeArray();
var formdata = {};
jQuery.each(form, function(i, e) {
formdata[e.name] = e.value;
});
$.ajax({
url:'/stop',
type:'POST',
contentType : 'application/json; charset=utf-8',
data: JSON.stringify(formdata)
}).done( (data) => {
console.log("success");
console.log(data);
}).fail( (data) => {
console.log("fail");
console.log(data);
});
}
/*--------------------------------
* タスクが完了
--------------------------------*/
function taskFinish(){
setTimeout(() => {
taskStop();
alert("Finish!!");
$("#timerView").countdowntimer("stop", "stop");
stopbtnOn();
}, 1000);
}
/*--------------------------------
* ボタン制御:startボタンONの時
--------------------------------*/
function startbtnOn(){
$('#startButton').prop("disabled", true);
$('#stopButton').prop("disabled", false);
}
/*--------------------------------
* ボタン制御:stopボタンONの時
--------------------------------*/
function stopbtnOn(){
$('#startButton').prop("disabled", false);
$('#stopButton').prop("disabled", true);
}
サーバーサイド実装
Model層のクラス
まず、テーブルのデータを格納するためのTasksEntityクラス、DBに作成したテーブルを操作するためのTasksRepositoryクラスを作ります。
package jp.co.anyplus.Entity;
import java.sql.Timestamp;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name="tasks")
public class TasksEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="task_id")
private Long taskId;
@Column(name="task_name")
private String taskName;
@Column(name="task_category")
private String taskCategory;
@Column(name="task_starttime")
private Timestamp taskStartTime;
@Column(name="task_endtime")
private Timestamp taskEndTime;
@Column(name="userid")
private String userid;
public Long getTaskId() {
return taskId;
}
public void setTaskId(Long taskId) {
this.taskId = taskId;
}
public String getTaskName() {
return taskName;
}
public void setTaskName(String taskName) {
this.taskName = taskName;
}
public String getTaskCategory() {
return taskCategory;
}
public void setTaskCategory(String taskCategory) {
this.taskCategory = taskCategory;
}
public Timestamp getTaskStartTime() {
return taskStartTime;
}
public void setTaskStartTime(Timestamp taskStartTime) {
this.taskStartTime = taskStartTime;
}
public Timestamp getTaskEndTime() {
return taskEndTime;
}
public void setTaskEndTime(Timestamp taskEndTime) {
this.taskEndTime = taskEndTime;
}
public String getUserid() {
return userid;
}
public void setUserid(String userid) {
this.userid = userid;
}
}
package jp.co.anyplus.Repository;
import java.sql.Timestamp;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jp.co.anyplus.Entity.TasksEntity;
@Repository
public interface TasksRepository extends JpaRepository<TasksEntity, Long> {
@Query(value="select current_timestamp", nativeQuery = true)
public Timestamp getCurrentTime();
@Query(value="select * from tasks where task_id = :taskId"
, nativeQuery = true)
public TasksEntity getByTaskId(@Param("taskId")Long taskId);
}
TasksRepository.javaで継承しているJpaRepositoryはSpring Data JPA(参考記事)の機能が使えるので、
今回のクエリの内容はSQLを書かずにメソッド名称から自動生成させることもできたようです。
Controller層と業務関連のクラス
ViewからModelへ流し込むためのController層廻りを作ります。
CotrollerクラスのIndexController.java、
ServiceクラスのTaskRegisterService.javaとTaskCancelService.java、
画面<->サーバー間でタスク情報の受け渡しを行うためのTaskForm.javaの全部で4クラスを用意しました。
// package、import文は省略
@Controller
public class IndexController {
@Autowired
TaskRegisterService registerService;
@Autowired
TaskCancelService cancelService;
/**
* 初期表示処理
*
* @param mav ModelAndView
* @return 初期表示情報
*/
@GetMapping("/")
public ModelAndView init(ModelAndView mav) {
TaskForm taskForm = new TaskForm();
mav.addObject("taskForm", taskForm);
mav.setViewName("index.html");
return mav;
}
/**
* タスク登録処理
*
* @param model Model
* @param taskForm タスク情報Form
* @return 新規採番されたタスクID
*/
@PostMapping("/")
@ResponseBody
public String taskRegister(Model model, @RequestBody TaskForm taskForm) {
try {
TasksEntity regTask = registerService.registerTask(taskForm);
return regTask.getTaskId().toString();
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
/**
* タスク終了処理
*
* @param model Model
* @param taskForm タスク情報Form
* @return 処理メッセージ
*/
@PostMapping("/stop")
@ResponseBody
public String taskEnd(Model model, @RequestBody TaskForm taskForm) {
try {
cancelService.endTask(taskForm);
} catch(RuntimeException e) {
e.printStackTrace();
return "error";
}
return "task canceling success";
}
}
画面とサーバのやり取りに使用している@ResponseBody、@PostMapping/@GetMapping、Modelクラスはそれぞれ理解するまでに至っておらず。
理解できたらソースの見直しとQiita記事化をしたいです。
// package、import文は省略
@Service
public class TaskRegisterService {
@Autowired
TasksRepository tasksRep;
@Transactional(rollbackOn = Exception.class)
public TasksEntity registerTask(TaskForm taskForm) throws RuntimeException {
Timestamp currenttime = tasksRep.getCurrentTime();
TasksEntity taskEntity = new TasksEntity();
taskEntity.setTaskName(taskForm.getTaskName());
taskEntity.setTaskCategory(taskForm.getCategory());
taskEntity.setTaskStartTime(currenttime);
taskEntity.setUserid("system");
try {
TasksEntity regTask = tasksRep.saveAndFlush(taskEntity);
return regTask;
} catch (RuntimeException e) {
// log:登録に失敗しました。
throw e;
}
}
}
// package、import文は省略
@Service
public class TaskCancelService {
@Autowired
TasksRepository tasksRep;
@Transactional(rollbackOn = Exception.class)
public void endTask(TaskForm taskForm) throws RuntimeException {
Timestamp currenttime = tasksRep.getCurrentTime();
TasksEntity taskEntity = tasksRep.getByTaskId(Long.parseLong(taskForm.getTaskId()));
taskEntity.setTaskId(Long.parseLong(taskForm.getTaskId()));
taskEntity.setTaskEndTime(currenttime);
try {
tasksRep.saveAndFlush(taskEntity);
} catch (RuntimeException e) {
// log:更新に失敗しました。
throw e;
}
}
}
// package、import文は省略
@SuppressWarnings("serial")
public class TaskForm implements java.io.Serializable {
private String taskId;
private String category;
private String taskName;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getTaskName() {
return taskName;
}
public void setTaskName(String taskName) {
this.taskName = taskName;
}
}