弊社新人の社内研修課題を勝手に作ってみよう
7月にもなり、弊社の新人も外部の研修を終え、
最近は追加で社内の研修を行っているようです。
その内容が
日々の日報を管理するアプリを作れ
とのことで、そんな難しいものではないのかな・・・?と思っていたのですが
日々作成されるExcel形式の日報ファイルをWeb画面からアップロードし、
提出状況をカレンダー形式で視覚的に管理・確認できるアプリを
要件定義、概要設計、詳細設計まではチームで行い
その後の実装は各々で行い最終人数分のアプリを並べて比較する
とのことで・・・
僕「え、厳しくない・・・?」
新人が8末までにこれらの要件設計実装単体結合と
もろもろのプレゼン資料までを作成するとのことだったので
5年目社員のわいなら簡単な要件定義とミニマムモデルくらいなら
1~2日でできるやろ!!!
と思い試しにやってみました
要件定義
概要要件
日々作成されるExcel形式の日報ファイルをWeb画面からアップロードし、
業務提出状況をカレンダー形式で視覚的に管理・確認できる、
シンプルで軽量な「日報管理システム」
データベースを使わず、テキストファイルのみで動作するスタンドアロン型のWebアプリケーションとして
ミニマムなモデルを作成する。
これは時間的な問題とコスト(人手)的な意味で
時間の限られている僕にできる範囲でやってみようというものです。
そのため、htmlなんかもhtml.appendでベタに打ち込んで
データベースも用意せずにプロジェクト内にテキストを配置して、
そこに書き込み上書きしていく形の
あまりにもお粗末なモノ
を作りました
Javaが得意とは言えないのでこれが限界でした・・・
機能要件
1. カレンダー表示機能
・月別カレンダー表示: 指定された年月のカレンダー(月曜日始まり)を画面に表示する。
・提出ステータスの可視化: 日報が提出済みの日は「○(背景緑)」、未提出の日は「×(背景白)」と色分けして表示する。
・前月・次月切り替え: リンク(←前月 / 次月→)をクリックすることで、表示する年月を切り替えられる。
基本的にはカレンダーのAPIでたいていは解決できるような気はするけど、
機能要件として書くのであればこのくらい機能として挙げていいしなんならもっと細かく書いてもいいと思う。(そのほうが工数確保できるからね)
2. 日報内容の確認機能
・非同期データ取得: カレンダーの日付をクリックした際、画面をリロードせずにその日の日報データを取得する。
※内部に保持するtxtファイルから非同期でデータを取得する。
・内容表示: 提出済みであれば、非同期データ取得をした日報のテキスト内容を表示する。
未提出であれば「日報はありません」と案内する。
クリックや保存ボタンをトリガーとしてテキストを何度もロードする方式
そのため、テキストに7~8年分もデータが溜まったら動作重たくなるんだろうなぁと思いつつ。
最低限ができりゃいいからええやろ!!!の気持ちでいる。
3. 日報のアップロード・データ取得
・日付指定アップロード: 画面上のカレンダーまたは日付選択フォームから、
特定の日付を指定してExcelファイル(.xlsx または .xlsm)をアップロードできる。
・Excelの解析: アップロードされたExcelファイルの「最初のシートのA1セル」の値を、
日報の本文として自動的に抽出する。
この辺りはApachePOIとかを活用すれば大抵何とかなるので、
実務の場合は先述の通りもっと仰仰しく書くところ。
4. データ管理
・ファイルベース管理: データをタブ区切り(日付 \t 内容)のテキストファイル(reports.txt)に保存する。
・自動ソート保存: 新しく日報が保存・更新されるたびに、
ファイル内のデータが日付の古い順(昇順)に自動で並び替えられて保存される。
・上書き機能:提出済みの月日に対して再提出がされた場合、reports.txtの対象年月日の内容を上書きする。
本来であればMySQLでも使ってDB管理にするべきなんだろうけど、
1~2日でデータベース管理までは無理かなって。
本来であればログイン機能があっての日報提出だろうから
人テーブル、日報テーブルくらいは最低限必要で
機能が増えるごとに考えるべきテーブルレイアウトは増えるので
今回はいったんなし!
非機能要件
開発言語:JDK 26
Webサーバー:Java標準組み込みの com.sun.net.httpserver.HttpServer(Tomcat等は不要)
Excel解析:Apache POI ライブラリ
データベース:なし(java.nio.file.Files を用いたテキストファイル制御)
フロント構造:JSフレームワークを使わない、javaに直接html.appendで作成する
社内研修レベルで非機能要件までまとめる必要があるかはわからないけれど、まぁ普通に必要だから
簡単な部分だけ
各種フローのイメージ
- ユーザーが画面で日付を選び、日報Excelをアップロードする。
- データを受け取り、一時ファイルを作成する。
- 一時Excelの「(A1)セル」を読み取り、テキストデータにする。
- テキストファイル(reports.txt)の既存データと合体させ、日付順にソートして上書き保存する。
- カレンダー画面をリロードすると、その日のステータスが「○」に変わる。
画面イメージ
ざっくりこんな感じで作りたいなのイメージ画像。
基本的にjavaのAPIやベタ打ちで基本的な操作で打ち込むとこうなるだろうな?という感じで
ざっくりしたイメージを作成
ざっくりイメージ
だいたい最小限にまとめたら
このくらいで実現できるのではないかな??という範囲
では作っていこう
(すでに作ったものを分割して解説します)
(面倒そうな部分を抜粋しています)
実装
ReportApp.java
最もメインで動くJavaなのでまずはここになるので
まずはエントリーとなる部分をざっくりと
/**
* アプリケーションの起動エントリーポイント。
* サーバーの初期化、ルーティングの設定、および開始を行う。
*/
public static void main(String[] args) throws Exception {
// 1. データの保存先ファイルを決定 (reports.txt)
Path dataFile = Path.of(args.length > 0 ? args[0] : "reports.txt");
ReportStore store = new ReportStore(dataFile);
ExcelReportParser parser = new ExcelReportParser();
// 2. ポート8080でHTTPサーバーを作成
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
// 3. ルートパスに対するすべてのリクエストハンドラーを設定
server.createContext("/", exchange -> {
try {
String method = exchange.getRequestMethod();
// リクエストメソッド(GET/POST)に応じて処理を振り分ける
if ("GET".equals(method)) {
handleGet(exchange, store);
} else if ("POST".equals(method)) {
handlePost(exchange, store, parser);
} else {
// GET/POST 以外は 405 を返す
//sendText(exchange, 405, "Method Is Not Allowed");
sendText(exchange, 405, "HTTPのメソッドが想定外だよ!!");
}
} catch (Exception e) {
// サーバー内部で例外が発生した場合はスタックトレースを出力し、500エラーを返す
e.printStackTrace();
try {
sendText(exchange, 500, e.getMessage());
} catch (Exception ignored) {
}
} finally {
// 接続を閉じる
exchange.close();
}
});
// 4. サーバーの稼働を開始
server.start();
System.out.println("アプリケーションを起動しました http://localhost:8080");
}
これでアプリケーションの大枠から起動までは完了するので
続きを用意していく
/**
* GETリクエストを処理するメソッド。
* 日報データの取得(API)および、カレンダー画面(HTML)の描画を行う。
*/
private static void handleGet(HttpExchange exchange, ReportStore store) throws IOException {
URI uri = exchange.getRequestURI();
String path = uri.getPath();
// パターンA:個別の日報データをJSONで返すAPIリクエストの場合
if ("/api/reports".equals(path)) {
String dateParam = getQueryParam(uri, "date");
// 日付パラメータの必須チェック
if (dateParam == null || dateParam.isBlank()) {
sendJson(exchange, "{\"error\":\"date is required\"}");
return;
}
// パラメータの文字列をLocalDateに変換
LocalDate date = LocalDate.parse(dateParam, INPUT_FORMAT);
// 日報の提出有無と内容を取得し、レスポンスを作成して返す
boolean submitted = store.hasReport(date);
String content = submitted ? store.getReport(date) : "";
String body = "{\"date\":\"" + date + "\",\"submitted\":" + submitted + ",\"content\":" + jsonString(content) + "}";
sendJson(exchange, body);
return;
}
// パターンB:ブラウザへカレンダー画面(HTML)を返す場合
String monthParam = getQueryParam(uri, "month");
// パラメータがなければ当月表示、指定があれば指定された月を解析
YearMonth month = monthParam == null || monthParam.isBlank()
? YearMonth.now()
: YearMonth.parse(monthParam, MONTH_FORMAT);
// 生成したHTMLをレスポンスとして送信
sendHtml(exchange, renderPage(month, store));
}
Getリクエストを処理していく。
今回の場合は保存ボタンを押下してAPIからエクセルのデータを取得する部分と
カレンダーの情報を取得する部分
二つのGETメソッドがあるのでそれらをまとめてパターン分けしている。
/**
* POSTリクエストを処理するメソッド。
* アップロードされたExcelファイルから日報内容を抽出し、保存します。
*/
private static void handlePost(HttpExchange exchange, ReportStore store, ExcelReportParser parser) throws Exception {
// パスが "/api/reports" でない場合は 404 Not Found を返す
if (!"/api/reports".equals(exchange.getRequestURI().getPath())) {
sendText(exchange, 404, "Not Found");
return;
}
// 日付パラメータの取得と日付の必須チェック
String dateParam = getQueryParam(exchange.getRequestURI(), "date");
if (dateParam == null || dateParam.isBlank()) {
sendJson(exchange, "{\"error\":\"date is required\"}");
return;
}
LocalDate date = LocalDate.parse(dateParam, INPUT_FORMAT);
Path tempFile = null;
try (InputStream inputStream = exchange.getRequestBody()) {
// 1. アップロードされたリクエストボディを一時ファイルとして保存
tempFile = saveUploadToTempFile(inputStream);
// 2. Excel解析パーサーを使って、A1セルの値を取得する
String content = parser.readFirstCellValue(tempFile);
// 3. 読み取った内容を保存
store.saveReport(date, content);
// 4. レスポンスを返却
String response = "{\"date\":\"" + date + "\",\"submitted\":true,\"content\":" + jsonString(content) + "}";
sendJson(exchange, response);
} finally {
// 5. 処理が終わったら、作成した一時ファイルを削除する
if (tempFile != null) {
Files.deleteIfExists(tempFile);
}
}
}
/**
* HTTPの入力ストリームからデータを受け取り、サーバー上の一時ファイルに書き出すメソッド。
*/
static Path saveUploadToTempFile(InputStream inputStream) throws IOException {
// 一時ファイルの作成
Path tempFile = Files.createTempFile("daily-report-", ".xlsx");
try (OutputStream out = Files.newOutputStream(tempFile, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
// 入力全データを一括で出力ストリームへ転送
inputStream.transferTo(out);
}
return tempFile;
}
データ保持やらの受け渡しメソッド。
正直教科書通りのつくりなので実際の業務だともっとJSON変換とかするような気はする
ExcelReportParser.java
アップロードされたExcelファイルを解析しApachePOIを利用して
内容を取得、操作するためのメソッドをまとめた部品
Excelファイルを開いて、一番左上のセル(A1)を読んで文字として返すだけ。
読めなかったら空文字を返す。といったシンプルな仕組みにしてある。
処理の順番としては
Excelファイルを開く
最初のシートを見る
一番上の行を見る(行をここで固定)
一番左のセルを見る(列の最初を見る→結果A1セルを見る)
その値を文字列として返す
の順番で処理を行う
public String readFirstCellValue(Path excelFile) throws IOException {
//読み込み終わったら自動的にメモリ上のファイルを閉じる
try (Workbook workbook = WorkbookFactory.create(excelFile.toFile())) {
// 1. 最も左側にあるシート (インデックス: 0)を取得
Sheet sheet = workbook.getSheetAt(0);
if (sheet == null) {
return ""; // シート自体が存在しない場合は空文字を返す(ありえない状況だが、破損などの考慮)
}
// 2. シートの1行目 (インデックス: 0)を取得
Row row = sheet.getRow(0);
if (row == null) {
return ""; // 1行目に何もデータが書き込まれていない場合は空文字を返す
}
// 3. 行の中の1列目 / A列 (インデックス: 0)を取得
Cell cell = row.getCell(0);
if (cell == null) {
return ""; // A1セルがない、または完全に空(ブランク)の場合は空文字を返す
}
// 4. セルに入力されているデータの種類(型)に応じて、取得方法を切り替えて文字列で返す
// switch式をお試しで使ってみています
return switch (cell.getCellType()) {
// 文字列型の場合
case STRING -> cell.getStringCellValue();
// 数値型の場合、実数の文字列に変換
case NUMERIC -> Double.toString(cell.getNumericCellValue());
// 理論値の場合、その文字列を返す(TRUEとかFALSEとか)
case BOOLEAN -> Boolean.toString(cell.getBooleanCellValue());
// Excelの数式・計算式が埋め込まれている場合、式そのものを文字列で返す(Excelであることの考慮)
case FORMULA -> cell.getCellFormula();
// 上記以外の状態(空セルやエラーセルなど)はすべて空文字を返す
default -> "";
};
}
}
getStringCellValue()みたいなやつで取得しようとすると日報に数値が入っていた場合にエラーになったりするので、Excelを使っているということをある程度考慮するのが必要かと考える
今回の場合は問題ないのだが、業務上Excel形式の帳票を読み込む際などに
try-with-resourcesで自動的にメモリ内に保持しているExcelのシートを閉じるように意識しておくと
処理時間軽減ができるので覚えておくといいと思う
(僕はそれで数日悩んだ)
ReportStore.java
日付 タブ 内容のテキストを編集し
テキストに保存するためのメソッド
簡易的なデータベースを作成するメソッドである
/**
* 日報データのを管理するデータストアクラス。
* データを「日付 [タブ] 内容」形式のテキストファイルで管理する。
*/
public class ReportStore {
// データを保存するテキストファイルのパス
private final Path dataFile;
/**
* 保存先ファイルを指定してインスタンスを生成します。
*/
public ReportStore(Path dataFile) {
this.dataFile = dataFile;
}
/**
* 指定された日付の日報を保存(追加または更新)するメソッド。
*/
public void saveReport(LocalDate date, String content) throws IOException {
// 1. 現在ファイルにある全データを一度Mapに読み込む
Map<LocalDate, String> reports = loadReports();
// 2. 指定された日付のデータを新規追加または上書きする
reports.put(date, content);
// 3. 更新されたMapのデータをすべてファイルに書き戻す
writeReports(reports);
}
/**
* 指定された日付の日報内容を取得するメソッド。
* @return 日報の本文。データがない場合は空文字 "" を返す
*/
public String getReport(LocalDate date) throws IOException {
// 全データを読み込み、指定日付の値を抽出(なければデフォルト値の "" を返す)
return loadReports().getOrDefault(date, "");
}
/**
* すでに日報が提出されている日付の一覧を、古い順(昇順)にソートして取得する
* 再更新するときに必要かなと思ったけどいらなかった・・・
*/
public List<LocalDate> listSubmittedDates() throws IOException {
return loadReports().keySet().stream() // Mapの日付のみをストリーム化
.sorted() // 日付順に並び替え
.collect(Collectors.toList()); // Listに変換して返却
}
/**
* 指定された日付の日報がすでに存在(提出)しているかをチェックする
* @return 存在すれば true、なければ false
*/
public boolean hasReport(LocalDate date) throws IOException {
// Mapのキーに該当の日付が含まれているかで判定
return loadReports().containsKey(date);
}
/**
* テキストファイルからすべてのデータを読み込み、Mapオブジェクトに変換する
* ※データの順序を保持するためHashMapでデータの順序を担保する
*/
public Map<LocalDate, String> loadReports() throws IOException {
// 1. ファイルが存在しない場合は、空のMapを返す
if (!Files.exists(dataFile)) {
return new LinkedHashMap<>();
}
// 2. ファイルの全行を行ごとに読み込む
List<String> lines = Files.readAllLines(dataFile);
Map<LocalDate, String> reports = new LinkedHashMap<>();
for (String line : lines) {
// 空白行はスキップする
if (line.isBlank()) {
continue;
}
// 3. 行を最初のタブ文字を境に2つに分割する
// 日付文字列 日報内容の二つに分かれる
String[] parts = line.split("\\t", 2);
if (parts.length == 2) {
// 文字列の日付を LocalDate に変換してMapに保持する
reports.put(LocalDate.parse(parts[0]), parts[1]);
}
}
return reports;
}
/**
* メモリ上の日報データを日付順に並び替え、テキストファイルへ一括書き込みする
*/
private void writeReports(Map<LocalDate, String> reports) throws IOException {
// 1. Mapのデータを日付順にソートし、 日付 [タブ] 内容 の文字列リストにする
List<String> lines = reports.entrySet().stream()
.sorted(Map.Entry.comparingByKey()) // 日付(昇順)でソート
.map(entry -> entry.getKey() + "\t" + entry.getValue()) // タブ区切りの文字列にする
.collect(Collectors.toList());
// 2. 保存先ファイルのフォルダが存在しない場合は自動作成する
Path parent = dataFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
// 3. ファイルにリストのデータを上書き保存する
Files.write(dataFile, lines);
}
}
実務ではこの雰囲気の処理が主体になることはなくて、
帳票取得→CSV吐き出しとかのバッチ処理とかで
もしかしたら見るかもしれない
今回の場合はおそまつ作成なのでこれでヨシ
ひとまず完成
testロジックは各種メソッドにダミーデータを与えてエラーになるかならないかの判別をするためだけど
半自動テストロジックです
必要ないけど作ってみたかった(テストの自動化って新しい技術畑でまだやったことないのであこがれる)
起動
ローカルホストでぽち・・・
とってもシンプルだけどミニマムモデル兼新人に見せるくらいならこれでちょうどいいんじゃなかろうか
?
ではエクセルを用意して7月10日にアップしてみる

アップロード後に7月10日をクリックすると

よしよしよし
report.txtにも昇順で日報が並んでいることも確認できました
ひとまずはこれでいいのかなと思います
今後
まずはDBを作成してテーブルの設計を考える
その後この一連の流れをDBでの管理に置き換えたのち
HTMLをしっかり作成する
※単一画面なら今回のつくり方でいいんだけど、
複数画面にまたがるようにするのであればちゃんと作らないと大変
後は各々やりたい機能とかアイデアを盛り込んでもらえれば課題としては満点じゃないでしょうか
おわりに
今回は1~2日で新人の課題のミニマムモデルを作ってみました。
最大限に楽をしていまして、インデントを整えたり、コメントアウトした解説を新人向けにかみ砕くところなんかはAIを使ってみました。
便利だなぁ・・・
ちなみに余談ですが、完成して満足したのち、この記事を作成する前に
要件を再度事務的な形でまとめてAIにこの要件を満たすものを作成してと頼んでみたら
もっときれいな(なんならCSSでちょっとかっこいい)ものが数分で作られました。
しかもコピペで動く。
う~んこの。


