はじめに
Javaの標準GUIツールキットである Swing と、組み込みDBの SQLite(JDBC) を組み合わせて、Discordのような「サーバー・チャンネル・チャット・メンバー一覧」を持つミニチャットアプリを作成しました。
これまでの学習(お絵描きアプリ、リズムゲーム)と違い、この題材ではデータの永続化とレイヤードアーキテクチャ(層ごとの責務分離)がテーマになっています。
- SQLiteによるユーザー・サーバー・チャンネル・メッセージの永続化
-
model/repository/service/uiに分かれたレイヤー構成 - サーバー切り替え時にチャンネル一覧・メンバー一覧を動的に更新
- チャンネルを選択するとDBからメッセージ履歴を読み込んで表示
- メッセージ送信でDBへの保存とチャット欄への即時反映
この記事では、DB設計とレイヤードアーキテクチャの組み立て方を中心に解説します。
完成イメージ
3カラムの、いわゆる「Discord風」レイアウトです。
+--------+------------------+---------------------------+--------+
| | Channels | |Members |
| JAVA |------------------| |--------|
| GAME | # general | hiroki 14:20 |🟢 hiroki|
| MUSIC | # random | こんにちは! |🟢 Alice |
| | | |⚫ Bob |
| | | Alice 14:21 | |
| | | よろしく! | |
| | |---------------------------| |
| | | [メッセージ入力欄] [送信] | |
+--------+------------------+---------------------------+--------+
左からサーバー一覧、チャンネル一覧、チャット本体、メンバー一覧という並びです。
アーキテクチャ:4層構成
このアプリは15個のクラスを、役割ごとに4つのパッケージへ分割しています。
| パッケージ | クラス | 役割 |
|---|---|---|
db |
DatabaseManager |
SQLite接続の取得、テーブルの初期化(DDL実行) |
model |
User, Server, Channel, Message
|
DBのレコードに対応するデータクラス |
repository |
UserRepository, ServerRepository, ChannelRepository, ServerUserRepository, MessageRepository
|
テーブルごとのCRUD処理をカプセル化 |
service |
ChatService |
メッセージ送受信のビジネスロジック |
ui |
MainFrame, ChannelPanel, ChatPanel, MemberPanel
|
画面表示とユーザー操作の受付 |
依存関係は以下のように上から下へ一方向に流れます。
ui (MainFrame / ChannelPanel / ChatPanel / MemberPanel)
│
▼
service (ChatService)
│
▼
repository (UserRepository / ServerRepository / ChannelRepository / ServerUserRepository / MessageRepository)
│
▼
db (DatabaseManager) ── model (User / Server / Channel / Message)
repository層はmodelのオブジェクトを受け取り・返却しつつ、実際のSQL実行はdb.DatabaseManagerから取得したConnectionを使う、という典型的なリポジトリパターンの構成です。
データベース設計:SQLiteのテーブル構成
DatabaseManager.initializeDatabase()で、アプリ起動時に5つのテーブルをCREATE TABLE IF NOT EXISTSでまとめて作成しています。
String channelSql = """
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (server_id)
REFERENCES servers(id)
)
""";
String serverUserSql = """
CREATE TABLE IF NOT EXISTS server_users (
server_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id),
FOREIGN KEY (server_id)
REFERENCES servers(id),
FOREIGN KEY (user_id)
REFERENCES users(id)
)
""";
Java 15以降のテキストブロック(""")を使うことで、複数行のSQL文をエスケープなしで見やすく記述できています。
テーブル構成をER図にすると以下のようになります。server_usersが「サーバーとユーザーの多対多」を表す中間テーブルになっている点がポイントです。
- 1つの
Serverは複数のChannelを持つ(1対多) -
ServerとUserはserver_usersを介した多対多(1人のユーザーが複数のサーバーに所属できる) -
MessageはChannelとUserの両方に紐づく
モデル層:DBレコードに対応するデータクラス
modelパッケージのクラスは、いずれもフィールドとgetter/setterだけを持つシンプルなデータクラスです。例えばChannelはこの通りです。
public class Channel {
private int id;
private String name;
private List<Message> messages;
public Channel(int id, String name) {
this.id = id;
this.name = name;
this.messages = new ArrayList<>();
}
public Channel(String name) {
this.name = name;
this.messages = new ArrayList<>();
}
// getter/addMessageのみ
}
Channelが保持するmessages(List<Message>)は、DBの内容をキャッシュするためのフィールドです。実際にDBからメッセージを取得する処理(MessageRepository.findByChannelId)とは別に、読み込んだ結果をChannelオブジェクト自身にも保持させることで、ChatPanel側は「今表示しているチャンネルのChannelオブジェクトを見ればメッセージ一覧がわかる」という単純な参照で済むようになっています。
Serverも同様に、List<Channel> channelsとList<User> usersを保持しており、「1つのサーバーに何のチャンネルがあり、誰が参加しているか」をオブジェクトのツリー構造としてメモリ上に表現しています。
リポジトリ層:テーブルごとに1クラス
repositoryパッケージには5つのクラスがあり、それぞれ1つのテーブルに対応するsave/existsByName/findByXxxメソッド群を持っています。共通するパターンをUserRepositoryで見てみます。
public void save(User user) {
if (existsByName(user.getName())) {
User existingUser = findByName(user.getName());
if (existingUser != null) {
user.setId(existingUser.getId());
}
System.out.println("ユーザーは既に登録されています:" + user.getName());
return;
}
String sql = """
INSERT INTO users (name, online)
VALUES (?, ?)
""";
try (Connection connection = DatabaseManager.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, user.getName());
statement.setInt(2, user.isOnline() ? 1 : 0);
statement.executeUpdate();
try (ResultSet resultSet = statement.getGeneratedKeys()) {
if (resultSet.next()) {
int id = resultSet.getInt(1);
user.setId(id);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
}
ここでの設計上のポイントは2つあります。
-
save()が「取得または新規作成(get-or-create)」として振る舞う
UPDATE文は存在せず、同名レコードがすでにあればINSERTせずにそのIDを引数のオブジェクトへ詰め直すだけで終わります。「同じ名前のユーザーを何度登録しようとしても1件しか作られない」という、簡易的な冪等性(べきとうせい)をこのパターンで実現しています。 -
PreparedStatementをRETURN_GENERATED_KEYS付きで発行し、オートインクリメントのIDを取得する
INSERT直後にstatement.getGeneratedKeys()でDBが採番したIDを取り出し、Java側のオブジェクトに反映しています。これにより、以降の処理(例えばメッセージのuser_id)で正しい外部キーを使えるようになります。
すべてのメソッドでtry-with-resourcesによりConnection/PreparedStatement/ResultSetを確実にクローズしている点も、JDBCコードとして基本に忠実な実装です。
JOINを使った関連データの取得
server_usersという中間テーブルを介した多対多関係は、ServerUserRepository.findUsersByServerIdでJOINクエリとして表現されています。
String sql = """
SELECT u.id, u.name, u.online
FROM users u
INNER JOIN server_users su
ON u.id = su.user_id
WHERE su.server_id = ?
""";
同様にMessageRepository.findByChannelIdでも、メッセージ本文だけでなく投稿したユーザー情報(usersテーブル)を一緒にJOINで取得しています。
String sql = """
SELECT
u.id, u.name, u.online,
m.content, m.created_at
FROM messages m
INNER JOIN users u
ON m.user_id = u.id
WHERE m.channel_id = ?
ORDER BY m.id
""";
こうすることで、Messageオブジェクトを組み立てる際に追加のクエリを発行せずに投稿者のUser情報まで一度に取得できます(いわゆるN+1問題を避けた設計)。
サービス層:ChatServiceの役割
serviceパッケージのChatServiceは、ChatPanel(UI)とMessageRepository(DB)の間に立つ薄いビジネスロジック層です。
public Message sendMessage(Channel channel, User user, String text) {
if (text == null || text.isBlank()) {
return null;
}
String time = LocalTime.now()
.format(DateTimeFormatter.ofPattern("HH:mm"));
Message message = new Message(user, text, time);
channel.addMessage(message);
messageRepository.save(message, channel.getId());
return message;
}
ここでの役割分担は明確です。
-
入力チェック(空文字・null)はUI側ではなく
ChatServiceが担当する -
タイムスタンプの生成(
HH:mm形式)もサービス層の責務とし、Messageオブジェクトの生成をここに集約する - メモリ上の
Channel.messagesへの追加と、DBへの永続化(messageRepository.save)を1つのメソッドの中で同時に行う
こうすることで、ChatPanel側は「sendMessageを呼べば、バリデーション・保存・タイムスタンプ付与がすべて完了したMessageが返ってくる」という単純なインタフェースだけを意識すればよくなります。
UI層:3カラムのDiscord風レイアウト
MainFrameが全体のエントリポイントで、BorderLayoutで「サーバー一覧(WEST)」「チャンネル+チャット(CENTER)」「メンバー一覧(EAST)」の3カラムを組み立てています。
add(serverPanel, BorderLayout.WEST);
add(centerPanel, BorderLayout.CENTER);
add(memberPanel, BorderLayout.EAST);
サーバー切り替えボタンのイベントでは、ChannelPanelとMemberPanelの両方を同時に更新しています。
javaButton.addActionListener(e -> {
channelPanel.setChannels(javaServer.getChannels());
memberPanel.setMembers(javaServer.getUsers());
});
チャンネル側はChannelPanelが「チャンネルボタンを動的に生成し、押されたらChatPanel.setChannel()を呼ぶ」というシンプルな橋渡し役です。
for (Channel channel : channels) {
JButton channelButton = new JButton("# " + channel.getName());
channelButton.addActionListener(e -> {
chatPanel.setChannel(channel);
});
channelListPanel.add(channelButton);
}
そしてChatPanel.setChannel()が、選択されたチャンネルのメッセージ履歴をChatService経由でDBから読み込み、JTextAreaに描画し直します。
public void setChannel(Channel channel) {
this.channel = channel;
List<Message> messages = chatService.loadMessages(channel);
channel.getMessages().clear();
for (Message message : messages) {
channel.addMessage(message);
}
messageArea.setText("");
for (Message message : channel.getMessages()) {
messageArea.append(
message.getUser().getName() + " " + message.getTime()
+ "\n" + message.getText() + "\n\n"
);
}
}
「押したボタン → 対象のChannelを選択 → DBからメッセージ取得 → 画面に反映」という一連の流れが、UIコンポーネント同士のメソッド呼び出しだけで完結しているのが読み取りやすいポイントです。
MemberPanelはさらにシンプルで、オンライン状態を絵文字(🟢/⚫)に変換してJLabelを並べているだけです。
String status = user.isOnline() ? "🟢" : "⚫";
JLabel memberLabel = new JLabel(status + " " + user.getName());
まとめ
このミニDiscordアプリの設計で押さえておきたいポイントは以下の4つです。
-
db/model/repository/service/uiの5パッケージで責務を分離し、依存の向きを上(ui)から下(db)へ一方向にそろえる - リポジトリパターンで「1テーブル1クラス」の原則を徹底し、SQLをUI・ビジネスロジックから隠蔽する
-
save()をget-or-createとして設計し、重複登録を防ぎつつ既存レコードのIDを取得できるようにする - JOINクエリで関連データを一度に取得し、UI表示に必要な情報(投稿者名やオンライン状態)を効率よく組み立てる
Swing単体の学習だった過去のlessonと比べ、この題材では「GUIとDBをどうつなぐか」「層をどう分けるか」という、より実務に近い設計判断が求められる内容になっていました。
今後の改善案
-
MainFrameがServerRepositoryやChannelRepositoryを直接呼び出しており、ChatServiceのようなサーバー・チャンネル用のサービス層が存在しない。UI層とリポジトリ層の間に一貫してサービス層を挟むと責務がより明確になる -
findByNameがヒットしない場合にnullを返す設計のため、事前にサーバーやユーザーのデータがDBに投入されていないとMainFrame内でNullPointerExceptionが発生する。初回起動時のシードデータ投入処理を用意すると安全 -
User.onlineの状態を更新するUPDATE文が存在しない(登録時の初期値のまま)。ログイン・ログアウトに応じてオンライン状態を更新する仕組みを追加する余地がある -
Connectionをメソッド呼び出しのたびに新規作成しているため、頻繁な操作ではコネクションプーリング(HikariCPなど)の導入を検討する - メッセージ送信が自分の画面にしか反映されない(他クライアントへのリアルタイム同期がない)ため、複数人でのチャットを想定するならソケット通信やポーリングの追加が必要