同じ商品にLIKEしたユーザーを1名マッチングする
こんにちは。ゆうせいです。
新人研修中に受講者から以下の質問をいただきました。
同じ商品を選択したユーザー1名を表示するにはどうしたらいいですか?
今回は、この質問に答えたいと思います。
「ユーザー同士のマッチング」を作ります。
条件は次の通りです。
| 条件 | 内容 |
|---|---|
| 目的 | 同じ商品にLIKEしたユーザーを1名見つける |
| ユーザーアクション | LIKEだけ使う |
| DAO | SuperDaoを継承する |
| DTO | 使う |
| 実行方法 | DAOクラスのmainメソッドから実行する |
イメージとしては、マッチングアプリや趣味コミュニティに近いです。
たとえば、ユーザー1が「Java入門書」にLIKEしているとします。
ユーザー2も同じ「Java入門書」にLIKEしていたら、ユーザー1とユーザー2は少し好みが近そうですよね。
今回は、このように「同じ商品にLIKEしたユーザー」を1名だけ取得します。
今回作るマッチングの考え方
今回のマッチングでは、対象ユーザーと同じ商品にLIKEした別ユーザーを探します。
同じLIKE商品が多いユーザーほど、マッチング候補として優先します。
たとえば、次のようなデータがあるとします。
| ユーザー | LIKEした商品 |
|---|---|
| ユーザー1 | Java入門書、MySQL入門書 |
| ユーザー2 | Java入門書、MySQL入門書、Spring Boot入門書 |
| ユーザー3 | Docker入門書 |
| ユーザー4 | Java入門書 |
ユーザー1を対象にした場合、ユーザー2は2つの商品が一致しています。
ユーザー4は1つの商品が一致しています。
そのため、ユーザー2をマッチング対象として選びます。
文化祭でたとえるなら、好きな出し物が2つ同じ友達のほうが、1つだけ同じ友達より話が合いそうですよね。
使用するテーブル
今回は、次の3つのテーブルを使います。
| テーブル | 役割 |
|---|---|
| users | ユーザー情報を保存する |
| items | 商品情報を保存する |
| user_actions | ユーザーがLIKEした履歴を保存する |
usersテーブル
CREATE TABLE users (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL
);itemsテーブル
CREATE TABLE items (
item_id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
category VARCHAR(100) NOT NULL,
deleted_at DATETIME NULL
);user_actionsテーブル
CREATE TABLE user_actions (
action_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
action_type VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL
);今回は、action_typeにはLIKEだけを使います。
action_type = 'LIKE'
user_actionsは、ユーザーの行動ログです。
ログとは、あとから確認できるように残しておく記録のことです。
学校の出席簿に「誰がいつ出席したか」を記録するように、user_actionsには「誰がどの商品にLIKEしたか」を記録します。
サンプルデータ
動作確認用のサンプルデータです。
INSERT INTO users (name) VALUES
('山田'),
('佐藤'),
('鈴木'),
('田中');
INSERT INTO items (title, category, deleted_at) VALUES
('Java入門書', 'BOOK', NULL),
('Spring Boot入門書', 'BOOK', NULL),
('MySQL入門書', 'BOOK', NULL),
('Docker入門書', 'BOOK', NULL),
('古い教材', 'BOOK', NOW());
INSERT INTO user_actions (user_id, item_id, action_type, created_at) VALUES
(1, 1, 'LIKE', NOW()),
(1, 3, 'LIKE', NOW()),
(2, 1, 'LIKE', NOW()),
(2, 2, 'LIKE', NOW()),
(2, 3, 'LIKE', NOW()),
(3, 4, 'LIKE', NOW()),
(4, 1, 'LIKE', NOW());このデータでは、user_idが1の山田さんは、Java入門書とMySQL入門書にLIKEしています。
佐藤さんは、Java入門書とMySQL入門書の両方にLIKEしています。
田中さんは、Java入門書だけにLIKEしています。
そのため、山田さんのマッチング対象は佐藤さんになります。
SuperDao
まず、DB接続を担当するSuperDaoを使います。
SuperDaoは、ほかのDAOから継承される親クラスです。
package com.example.demo.model.dao;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
/**
* SuperDao - データベース接続の取得を担当する基底クラス
*/
public class SuperDao {
private static final String DB_URI =
"jdbc:mysql://localhost:3306/sip_a?"
+ "characterEncoding=utf8&"
+ "useSSL=false&serverTimezone=GMT%2B9&"
+ "rewriteBatchedStatements=true";
private static final String DB_USER = "newuser";
private static final String DB_PASS = "0";
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(DB_URI, DB_USER, DB_PASS);
}
}protectedのgetConnectionは、SuperDaoを継承した子クラスから使えます。
今回作るUserMatchingDaoは、SuperDaoを継承してDBに接続します。
DTOを作る
次に、マッチング結果を入れるDTOを作ります。
DTOとは、Data Transfer Objectの略です。
データを受け渡しするための専用クラスです。
今回のDTOには、マッチしたユーザー情報と、共通LIKE数、共通LIKE商品名を入れます。
| 項目 | 意味 |
|---|---|
| matchedUserId | マッチしたユーザーID |
| matchedUserName | マッチしたユーザー名 |
| commonLikeCount | 同じ商品にLIKEした数 |
| commonItemTitles | 共通してLIKEした商品名 |
MatchedUserDto.java
package com.example.demo.model.dto;
public class MatchedUserDto {
private Long matchedUserId;
private String matchedUserName;
private int commonLikeCount;
private String commonItemTitles;
public MatchedUserDto() {
}
public MatchedUserDto(Long matchedUserId, String matchedUserName, int commonLikeCount, String commonItemTitles) {
this.matchedUserId = matchedUserId;
this.matchedUserName = matchedUserName;
this.commonLikeCount = commonLikeCount;
this.commonItemTitles = commonItemTitles;
}
public Long getMatchedUserId() {
return matchedUserId;
}
public void setMatchedUserId(Long matchedUserId) {
this.matchedUserId = matchedUserId;
}
public String getMatchedUserName() {
return matchedUserName;
}
public void setMatchedUserName(String matchedUserName) {
this.matchedUserName = matchedUserName;
}
public int getCommonLikeCount() {
return commonLikeCount;
}
public void setCommonLikeCount(int commonLikeCount) {
this.commonLikeCount = commonLikeCount;
}
public String getCommonItemTitles() {
return commonItemTitles;
}
public void setCommonItemTitles(String commonItemTitles) {
this.commonItemTitles = commonItemTitles;
}
@Override
public String toString() {
return "MatchedUserDto{" +
"matchedUserId=" + matchedUserId +
", matchedUserName='" + matchedUserName + '\'' +
", commonLikeCount=" + commonLikeCount +
", commonItemTitles='" + commonItemTitles + '\'' +
'}';
}
}toStringメソッドを用意している理由は、mainメソッドで実行したときに中身を表示しやすくするためです。
SuperDaoを継承したUserMatchingDao
次に、実際にマッチングを行うDAOを作ります。
UserMatchingDaoのmainメソッドから直接実行します。
UserMatchingDao.java
package com.example.demo.model.dao;
import com.example.demo.model.dto.MatchedUserDto;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class UserMatchingDao extends SuperDao {
public MatchedUserDto findOneMatchedUserByLike(Long targetUserId) throws SQLException {
String sql =
"SELECT " +
" u.user_id AS matched_user_id, " +
" u.name AS matched_user_name, " +
" COUNT(DISTINCT my_action.item_id) AS common_like_count, " +
" GROUP_CONCAT(DISTINCT i.title ORDER BY i.title SEPARATOR ', ') AS common_item_titles " +
"FROM user_actions my_action " +
"JOIN user_actions other_action " +
" ON my_action.item_id = other_action.item_id " +
" AND other_action.action_type = 'LIKE' " +
" AND other_action.user_id <> my_action.user_id " +
"JOIN users u " +
" ON other_action.user_id = u.user_id " +
"JOIN items i " +
" ON my_action.item_id = i.item_id " +
"WHERE my_action.user_id = ? " +
"AND my_action.action_type = 'LIKE' " +
"AND i.deleted_at IS NULL " +
"GROUP BY " +
" u.user_id, " +
" u.name " +
"ORDER BY " +
" common_like_count DESC, " +
" u.user_id ASC " +
"LIMIT 1";
try (
Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)
) {
statement.setLong(1, targetUserId);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
MatchedUserDto dto = new MatchedUserDto();
dto.setMatchedUserId(resultSet.getLong("matched_user_id"));
dto.setMatchedUserName(resultSet.getString("matched_user_name"));
dto.setCommonLikeCount(resultSet.getInt("common_like_count"));
dto.setCommonItemTitles(resultSet.getString("common_item_titles"));
return dto;
}
}
}
return null;
}
public static void main(String[] args) {
UserMatchingDao dao = new UserMatchingDao();
Long targetUserId = 1L;
System.out.println("LIKEベースのユーザーマッチングを開始します。");
System.out.println("対象ユーザーID: " + targetUserId);
try {
MatchedUserDto matchedUser = dao.findOneMatchedUserByLike(targetUserId);
if (matchedUser == null) {
System.out.println("マッチング対象のユーザーは見つかりませんでした。");
return;
}
System.out.println("マッチング対象ユーザー:");
System.out.println(matchedUser);
} catch (SQLException e) {
System.err.println("ユーザーマッチング中にエラーが発生しました。");
System.err.println("原因: " + e.getMessage());
e.printStackTrace();
}
System.out.println("処理を終了します。");
}
}このSQLの意味
DAOの中で実行しているSQLを、単独で見ると次の形です。(「my_action.user_id = 1」で1番の人のマッチング候補者を表示しています)
SELECT
u.user_id AS matched_user_id,
u.name AS matched_user_name,
COUNT(DISTINCT my_action.item_id) AS common_like_count,
GROUP_CONCAT(DISTINCT i.title ORDER BY i.title SEPARATOR ', ') AS common_item_titles
FROM user_actions my_action
JOIN user_actions other_action
ON my_action.item_id = other_action.item_id
AND other_action.action_type = 'LIKE'
AND other_action.user_id <> my_action.user_id
JOIN users u
ON other_action.user_id = u.user_id
JOIN items i
ON my_action.item_id = i.item_id
WHERE my_action.user_id = 1
AND my_action.action_type = 'LIKE'
AND i.deleted_at IS NULL
GROUP BY
u.user_id,
u.name
ORDER BY
common_like_count DESC,
u.user_id ASC
LIMIT 1;長く見えますが、やっていることはシンプルです。
対象ユーザーがLIKEした商品を探します。
同じ商品にLIKEした別ユーザーを探します。
共通LIKE数が多いユーザーを上に並べます。
先頭の1名だけ取得します。
同じ商品にLIKEした別ユーザーを探す部分
JOIN user_actions other_action
ON my_action.item_id = other_action.item_id
AND other_action.action_type = 'LIKE'
AND other_action.user_id <> my_action.user_idこの部分が一番大事です。
my_actionは対象ユーザーのLIKE履歴です。
other_actionは他のユーザーのLIKE履歴です。
my_action.item_id = other_action.item_id によって、同じ商品にLIKEしたユーザーを探しています。
other_action.user_id <> my_action.user_id によって、自分自身を除外しています。
自分と自分をマッチングしても意味がありませんよね。
共通LIKE数を数える部分
COUNT(DISTINCT my_action.item_id) AS common_like_count
この部分では、同じ商品にLIKEした数を数えています。
同じ商品に2つ一致していれば2、1つだけ一致していれば1です。
数が多いほど、好みが近いユーザーだと考えます。
数式で書くと、次の形です。
commonLikeCount = COUNT(commonLikedItems)
共通LIKE数 = 共通してLIKEした商品の数
とてもシンプルですね。
共通LIKE商品名をまとめる部分
GROUP_CONCAT(DISTINCT i.title ORDER BY i.title SEPARATOR ', ') AS common_item_titles
GROUP_CONCATは、MySQLで複数行の値を1つの文字列にまとめる関数です。
たとえば、Java入門書とMySQL入門書が共通している場合、次のようにまとめます。
Java入門書, MySQL入門書
これにより、なぜそのユーザーがマッチしたのかを表示しやすくなります。
マッチングでは、「誰がマッチしたか」だけでなく、「なぜマッチしたか」も大切です。
理由が見えると、納得しやすいですよね。
1名だけ取得する部分
ORDER BY
common_like_count DESC,
u.user_id ASC
LIMIT 1ORDER BY common_like_count DESC によって、共通LIKE数が多いユーザーを上に並べます。
u.user_id ASC は、同じ共通LIKE数だった場合の並び順を安定させるためです。
LIMIT 1 によって、マッチング対象を1名だけ取得します。
つまり、「一番好みが近そうなユーザーを1人だけ選ぶ」ということです。
実行結果のイメージ
mainメソッドを実行すると、次のような結果になります。
LIKEベースのユーザーマッチングを開始します。
対象ユーザーID: 1
マッチング対象ユーザー:
MatchedUserDto{matchedUserId=2, matchedUserName='佐藤', commonLikeCount=2, commonItemTitles='Java入門書, MySQL入門書'}
処理を終了します。この結果は、ユーザー1とユーザー2が、Java入門書とMySQL入門書の2つに共通してLIKEしていることを表しています。
ユーザー4もJava入門書にLIKEしていますが、共通LIKE数は1です。
そのため、共通LIKE数が2のユーザー2が優先されます。
この構成のクラス一覧
今回必要なクラスは、次の3つです。
com.example.demo
└── model
├── dao
│ ├── SuperDao.java
│ └── UserMatchingDao.java
└── dto
└── MatchedUserDto.java
| クラス | 役割 |
|---|---|
| SuperDao | DB接続を取得する親クラス |
| UserMatchingDao | 同じ商品にLIKEしたユーザーを1名探す |
| MatchedUserDto | マッチング結果を入れる |
この方法のメリット
| メリット | 内容 |
|---|---|
| シンプルに作れる | LIKEだけを見るのでSQLが理解しやすい |
| 理由を説明しやすい | 同じ商品にLIKEしたからマッチ、と言える |
| DAO単体で試せる | mainメソッドからすぐ実行できる |
| DTOの役割がわかりやすい | マッチング結果だけをまとめて返せる |
特に新人エンジニアにとっては、「同じ商品にLIKEした人を探す」という条件がわかりやすいです。
複雑な機械学習を使わなくても、マッチング機能の基本は作れます。
この方法の注意点
| 注意点 | 内容 |
|---|---|
| LIKEが少ないとマッチしにくい | 共通する商品がないと候補が出ない |
| 人気商品だけでマッチする可能性がある | 誰でもLIKEする商品だと個性が出にくい |
| 1名だけだと偏る場合がある | 同率の候補が複数いても1名しか返さない |
| ブロックや非表示条件がない | 実務では除外条件が必要になる |
たとえば、全員がLIKEするような人気商品だけでマッチすると、本当に好みが近いとは限りません。
学校でたとえるなら、「給食のカレーが好き」という共通点だけで親友を決めるようなものです。
それだけでは少し弱いですよね。
将来的には、共通LIKE数だけでなく、商品のカテゴリ、最近LIKEしたかどうか、プロフィール情報なども組み合わせると精度が上がります。
まとめ
今回は、ユーザーのアクションをLIKEだけにして、同じ商品にLIKEしたユーザーを1名マッチングするDAOを作りました。
UserMatchingDaoのmainメソッドから直接実行する形です。
| ポイント | 内容 |
|---|---|
| マッチング条件 | 同じ商品にLIKEしていること |
| 優先順位 | 共通LIKE数が多いユーザーを優先 |
| 取得件数 | LIMIT 1で1名だけ取得 |
| DAO | SuperDaoを継承する |
| DTO | MatchedUserDtoを使う |
| 実行方法 | DAOのmainメソッドで確認する |
一言でまとめるなら、こうです。
LIKEベースのユーザーマッチングは、自分と同じ商品にLIKEした人を探し、共通LIKE数が多い人を1名選ぶ仕組みです。
まずはこのDAOを動かして、user_actionsのLIKEデータを増やしながら、どのユーザーがマッチするか確認してみてください。
今後は、共通LIKE数だけでなく、カテゴリ一致、最新LIKE日時、ブロック済みユーザー除外、すでにマッチ済みユーザー除外を追加すると、より実務に近いマッチング機能になります。まずは「なぜこのユーザーがマッチしたのか」をSQLで説明できる状態を目指しましょう!
セイ・コンサルティング・グループでは新人エンジニア研修のアシスタント講師を募集しています。
投稿者プロフィール


