【個人開発】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