【個人開発】Chatendarの友達検索機能を深掘り:Next.js(TS)とFlask(Python)での実装を解説
はじめに
このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。今回は、友達検索機能に焦点を当て、その具体的な実装方法をご紹介します。
もし、全体のコードをご覧になりたい方は、私のGitHubにて公開していますので、そちらからご確認いただけます。
アプリの全体像や概要を知りたい方は、以下のサイトからご確認ください。
友達検索画面
こちらが友達検索を利用する際の画面となっています。

検索したい人のユーザー名かメールアドレスを入力する。

検索ボタンを押すと結果が表示されます。そして、友達申請を送ることができます。(画面:リョータ)

その友達申請は相手側のフレンドリクエストに表示され、そのユーザーは承認と拒否を選択することができます。(画面:タナカ)

承認されると、それぞれの友達リストに表示されます。
紹介する機能
・「友達を検索」モーダルで任意のユーザーを検索する。
この機能を実装するためのコードを紹介します。
コード
ここでは、フロントエンド(client)とバックエンド(server)に分かれたコードを紹介します。
フロントエンド
'use client';
import PersonIcon from '@mui/icons-material/Person';
import React, { useState } from 'react';
import { User } from '@/types/User';
type SearchUserProps = {
closeSearchModal: () => void;
};
export default function SearchUser({ closeSearchModal }: SearchUserProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<User[]>([]);
const [noResults, setNoResults] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (query.trim() === '') return;
try {
const response = await api.get('/user/search', {
params: { query },
});
const data = response.data as User[];
if (data.length === 0) {
setNoResults(true);
} else {
setNoResults(false);
}
setResults(response.data as User[]);
setQuery('');
setError(null);
} catch (err) {
setError('検索に失敗しました');
}
};
// 一部省略
return (
<div className="space-y-4">
<form onSubmit={handleSearch} className="flex items-center space-x-2">
<input
className="w-full p-1 text-sm text-gray-700 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
type="text"
placeholder="ユーザー名かメールアドレスで検索"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button
type="submit"
className=" bg-blue-600 text-sm p-1 px-2 w-1/4 text-white rounded hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400"
>
検索
</button>
</form>
{error && <p className="text-red-500">{error}</p>}
{noResults && !error && (
<p className="text-red-500">ユーザーが存在しません。</p>
)}
<ul className="space-y-2">
{results.map((user) => (
<li key={user.id} className="px-4 py-2 cursor-pointer">
<span>
<PersonIcon className="mr-1" fontSize="small" />
{user.username}
</span>
<button
type="button"
onClick={() => handleSendRequest(user.id)}
className="bg-blue-600 text-white text-sm ml-2 py-1 px-3 rounded-lg hover:bg-blue-500 focus:outline-none"
>
友達申請
</button>
</li>
))}
</ul>
// 一部省略
</div>
);
}
const [query, setQuery] = useState('');
const [results, setResults] = useState<User[]>([]);
const [noResults, setNoResults] = useState(false);
const [error, setError] = useState<string | null>(null);
・query: 検索クエリ(ユーザー名やメールアドレス)を格納する状態。
・results: 検索結果のユーザーリストを格納する状態。
・noResults: 検索結果が存在しない場合にtrueとなるフラグ。検索結果が0件の場合にtrueとなる。
・error: 検索時にエラーが発生した場合、そのエラーメッセージを格納する状態。
この関数は、ユーザー検索を実行するための非同期関数です。
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (query.trim() === '') return;
try {
const response = await api.get('/user/search', {
params: { query },
});
const data = response.data as User[];
if (data.length === 0) {
setNoResults(true);
} else {
setNoResults(false);
}
setResults(response.data as User[]);
setQuery('');
setError(null);
} catch (err) {
setError('検索に失敗しました');
}
};
api.get()を使ってサーバーに検索クエリを送信し、/user/searchエンドポイントでユーザー検索を実行します。paramsオプションを使用して、queryの値をリクエストのパラメータとしてサーバーに送信します。
params: { query }
このコードでは、HTTPリクエストのクエリパラメータを指定しています。paramsは、APIリクエストを送る際に、URLの一部としてデータを渡すために使用されるプロパティです。ここでの{ query }は、プロパティショートハンドで、記述を簡略化しています。実際の形は、params: { query: query }となります。
クライアントからサーバーに送られるリクエストは、次のようになります。
/user/search?query=searchTerm
このquery=searchTermはparams: { query: query }で設定されたクエリパラメータの一部であり、queryがキーで、searchTermが値です。値には検索時に入力した内容が入ります。これは、inputタグでvalue={query}と指定されているためです。
次に、サーバーからのレスポンスに基づいて結果を更新します。
if (data.length === 0) {
setNoResults(true);
} else {
setNoResults(false);
}
setResults(response.data as User[]);
setQuery('');
setError(null);
結果が空の場合、noResultをtrueに設定します。
結果がある場合、resultsを更新し、検索クエリであるqueryをリセットします。また、エラー状態もリセットされます。
<form onSubmit={handleSearch} className="flex items-center space-x-2">
<input
className="w-full p-1 text-sm text-gray-700 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
type="text"
placeholder="ユーザー名かメールアドレスで検索"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button
type="submit"
className="bg-blue-600 text-sm p-1 px-2 w-1/4 text-white rounded hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400"
>
検索
</button>
</form>
inputフィールドでは、ユーザーが検索クエリを入力でき、そのクエリの状態はsetQueryを使って更新されます。
buttonは検索ボタンで、クリック時にフォームが送信され、handleSearch関数が実行されます。
<ul className="space-y-2">
{results.map((user) => (
<li key={user.id} className="px-4 py-2 cursor-pointer">
<span>
<PersonIcon className="mr-1" fontSize="small" />
{user.username}
</span>
<button
type="button"
onClick={() => handleSendRequest(user.id)}
className="bg-blue-600 text-white text-sm ml-2 py-1 px-3 rounded-lg hover:bg-blue-500 focus:outline-none"
>
友達申請
</button>
</li>
))}
</ul>
検索結果のリスト: resultsに保存されたユーザー情報をリスト形式で表示します。map関数を使用して、各ユーザーのidとusernameをレンダリングし、それぞれに友達申請ボタンを表示しています。
友達申請ボタン: 各ユーザーに対して友達申請を送るためのボタンです。クリックすると、指定したユーザーのidに対してリクエストを送るhandleSendRequest関数が実行されます。
バックエンド
from .. import db
from werkzeug.security import generate_password_hash, check_password_hash
friends_association_table = db.Table(
'friends',
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
db.Column('friend_id', db.Integer, db.ForeignKey('user.id')),
)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
friends = db.relationship(
'User',
secondary=friends_association_table,
primaryjoin=(friends_association_table.c.user_id == id),
secondaryjoin=(friends_association_table.c.friend_id == id),
backref=db.backref('friend_of', lazy='dynamic'),
lazy='dynamic',
)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
friends_association_table = db.Table(
'friends',
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
db.Column('friend_id', db.Integer, db.ForeignKey('user.id')),
)
・friends_association_tableは、ユーザー同士の友達関係を管理するための中間テーブルです。user_idとfrind_idを格納し、ユーザーと友達の多対多の関係を表現します。
・db.Tableを使って直接テーブルを定義し、それぞれのカラムはuserテーブルを参照する外部キーとして設定されています。
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
Userクラスは、ユーザー情報を管理するためのデータモデルで、db.Modelを継承しています。
・id: ユーザーごとに一意のIDで、主キーです。
・username: ユーザー名。80文字以内の文字列でユニークかつ必須です(unique=True, nullable=False)。
・email: ユーザーのメールアドレス。こちらもユニークかつ必須です。
・password_hash: パスワードをハッシュ化した文字列を保存します(128文字まで)。
friends = db.relationship(
'User',
secondary=friends_association_table,
primaryjoin=(friends_association_table.c.user_id == id),
secondaryjoin=(friends_association_table.c.friend_id == id),
backref=db.backref('friend_of', lazy='dynamic'),
lazy='dynamic',
)
・friendsリレーションシップは、ユーザーとその友達(他のユーザー)との多対多の関係を管理します。
・secondary=friends_association_table: 中間テーブルとして定義されたfriends_association_tableを使用します。
・primaryjoinとsecondaryjoin: primaryjoinはuser_idが現在のユーザーに対応し、secondaryjoinはfriend_idが友達のユーザーに対応します。
・backref: 逆方向の関係も設定され、ユーザーが他のどのユーザーに「友達」として追加されているかも追跡できます(friend_of)。
・lazy='dynamic': 遅延ロードを行い、必要に応じてリレーションシップのデータをクエリで取得します。
from flask import request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app import db
from app.models.user_model import User
from app.models.event_model import EventInvite, Event
@jwt_required()
def search_users():
query = request.args.get('query', '')
current_user_id = get_jwt_identity()
if not query:
return jsonify([])
current_user = User.query.get(current_user_id)
results = User.query.filter(User.username.ilike(f'%{query}%')).all()
filtered_results = [
user
for user in results
if user.id != current_user_id and user not in current_user.friends
]
return jsonify(
[{'id': user.id, 'username': user.username} for user in filtered_results]
)
このコードでは、ユーザー検索を行うAPIエンドポイントを定義しています。
query = request.args.get('query', '')
クライアントから送信されたURLパラメータqueryを取得します。例えば、クライアントが/search_users?query=johnというリクエストを送った場合、queryには"john"が格納されます。
current_user_id = get_jwt_identity()
JWTトークンから現在のユーザーIDを取得します。このIDは、検索結果から現在のユーザー自身を除外するために使用されます。
current_user = User.query.get(current_user_id)
データベースから現在認証されているユーザーの情報を取得します。
results = User.query.filter(User.username.ilike(f'%{query}%')).all()
・Userテーブル内のusernameカラムに対して部分一致検索を行っています。ilike()関数により、大文字・小文字を区別しない検索を行い、クエリに基づいてユーザー名・メールアドレスに一致するユーザーをデータベースから取得しています。
・f'%{query}%'は、部分一致のパターンを指定します。これにより、ユーザー名にクエリが含まれているすべてのレコードを取得できます。
filtered_results = [
user
for user in results
if user.id != current_user_id and user not in current_user.friends
]
・user.id != current_user_id: 現在のユーザー自身を検索結果から除外します。
・user not in current_user.friends: 現在のユーザーの友達リストに含まれていないユーザーのみを結果に含めます。これにより、すでに友達であるユーザーが検索結果に表示されません。
return jsonify(
[{'id': user.id, 'username': user.username} for user in filtered_results]
)
フィルタリングされた検索結果をJSON形式でクライアントに返します。各ユーザーのidとusernameを含む辞書オブジェクトのリストを生成して返します。
まとめ
今回のブログでは、私が開発したアプリ「Chatendar」の友達検索機能について、主要な実装部分を解説しました。
友達検索機能は、アプリケーションにおいてユーザーにとって視覚的にも機能的にも重要な部分です。今後も「Chatendar」の他の機能について、引き続き解説していく予定です。
Discussion