👬

【個人開発】Chatendarの友達検索機能を深掘り:Next.js(TS)とFlask(Python)での実装を解説

2024/10/23に公開

はじめに

このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。今回は、友達検索機能に焦点を当て、その具体的な実装方法をご紹介します。

もし、全体のコードをご覧になりたい方は、私のGitHubにて公開していますので、そちらからご確認いただけます。

https://github.com/R-koma/calendar-chat

アプリの全体像や概要を知りたい方は、以下のサイトからご確認ください。

https://qiita.com/ryoma_itngineer/items/1a45121a2317b47d2003

友達検索画面

こちらが友達検索を利用する際の画面となっています。
cc-friends-search.png
検索したい人のユーザー名かメールアドレスを入力する。

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

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

cc-add-frinds-list.png
承認されると、それぞれの友達リストに表示されます。

紹介する機能

・「友達を検索」モーダルで任意のユーザーを検索する。

この機能を実装するためのコードを紹介します。

コード

ここでは、フロントエンド(client)とバックエンド(server)に分かれたコードを紹介します。

フロントエンド

SearchUser.tsx
'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: 検索時にエラーが発生した場合、そのエラーメッセージを格納する状態。

この関数は、ユーザー検索を実行するための非同期関数です。

handleSearch関数
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=searchTermparams: { query: query }で設定されたクエリパラメータの一部であり、queryがキーで、searchTermが値です。値には検索時に入力した内容が入ります。これは、inputタグvalue={query}と指定されているためです。

次に、サーバーからのレスポンスに基づいて結果を更新します。

if (data.length === 0) {
      setNoResults(true);
    } else {
      setNoResults(false);
    }

    setResults(response.data as User[]);
    setQuery('');
    setError(null);

結果が空の場合、noResulttrueに設定します。
結果がある場合、resultsを更新し、検索クエリであるqueryをリセットします。また、エラー状態もリセットされます。

SearchUser.tsx
<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関数が実行されます。

SearchUser.tsx
<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関数を使用して、各ユーザーのidusernameをレンダリングし、それぞれに友達申請ボタンを表示しています。
友達申請ボタン: 各ユーザーに対して友達申請を送るためのボタンです。クリックすると、指定したユーザーのidに対してリクエストを送るhandleSendRequest関数が実行されます。

バックエンド

user_model.py
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_idfrind_idを格納し、ユーザーと友達の多対多の関係を表現します。

db.Tableを使って直接テーブルを定義し、それぞれのカラムはuserテーブルを参照する外部キーとして設定されています。

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リレーションシップ
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を使用します。
primaryjoinsecondaryjoin: primaryjoinuser_idが現在のユーザーに対応し、secondaryjoinfriend_idが友達のユーザーに対応します。
backref: 逆方向の関係も設定され、ユーザーが他のどのユーザーに「友達」として追加されているかも追跡できます(friend_of)。
lazy='dynamic': 遅延ロードを行い、必要に応じてリレーションシップのデータをクエリで取得します。

user_controller.py
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形式でクライアントに返します。各ユーザーのidusernameを含む辞書オブジェクトのリストを生成して返します。

まとめ

今回のブログでは、私が開発したアプリ「Chatendar」の友達検索機能について、主要な実装部分を解説しました。

友達検索機能は、アプリケーションにおいてユーザーにとって視覚的にも機能的にも重要な部分です。今後も「Chatendar」の他の機能について、引き続き解説していく予定です。

Discussion