この記事はマッチングアプリを自作してみようと試みている記事です
バックエンドを実装しています
前回の記事はこちらです
shinseidaiki.hatenablog.com
前回はDjangoの導入まで終わりました
次にモデルを作成していきましょう
目次
アーキテクチャ設計
さて、続きのAPIを作成する前に前回のガバガバなシステム構成図ではバックエンドの中身をどう作るのかについて全く未検討だったのでここでバックエンド側のアーキテクチャ設計を行います
具体的には、画面遷移図、DB設計、Model設計、View設計、RestAPI URI設計です
なお、Djangoにはコードを元にER図などのドキュメンテーションを作成してくれる機能があるため、ここでは簡単な絵図だけを描くことにします
画面遷移図
画面遷移図はバックエンドのアーキテクチャの設計のイメージがつきやすくなるためここで簡略図を作成する
ER図
ざっくりとテーブルのリレーションがわかるだけの簡略図を作ります
ユーザーの実装はDjangoにUserモデルが標準装備されているので、そのUserモデルをカスタマイズすることで実装するのが一般的となっている
その際、基本情報はUserモデルのフィールドとして持ち、nicknameなど新たに持たせたいフィールドは1対1のリレーションで新たに作成したProfileモデルに設定するのが一般的である
またマッチングクラスではいいねの送信者と受信者が双方向にいいねを送り合ったときに双方のapprovedをtrueに変更しマッチングとする設計にする
メッセージモデルに関しては単にUserモデルと1対多の関係性をとるのみとし、マッチングしているユーザー同士のみがメッセージのやり取りができる機能に関してはView側で実装していく
各モデルの他のフィールド(others......)に関しては実装時に具体的にしていく
RestAPI 設計
次にバックエンドで作成するRestAPIの機能とそのURIエンドポイントを設計します
原則としてREST APIはすべてログイン中のユーザーのみが操作できるようにして、それ以外のアクセスは拒否します
エンドポイント | 機能内容 |
/api/authen/ | ユーザー認証に利用される |
/api/users/create/ | ユーザーを作成することができる |
/api/users/{pk}/ | 自分自身のユーザー情報を取得・編集できる。他人のユーザー情報は取得・編集できない |
/api/users/profile/{pk} | 自分自身のプロフィール情報を作成・取得・編集できる。他人のプロフィール情報は取得・編集できない |
/api/profiles/ | 自身のプロフィールを作成したり、異性のプロフィール一覧を取得することができる。自分のプロフィールがない状態ではプロフィール一覧を取得することはできない |
/api/favorite/ | いいねを送ったユーザといいねをくれたユーザ一覧を取得したりいいねを送ったり、いいねを承認したりすることができる |
/api/dm-message/ | ユーザー自身が送信したDMの一覧を取得する。新たにメッセージを送信することができる |
/api/dm-inbox/ | ユーザー自身が受信したDMの一覧を取得する |
Djangoの初期設定~設定項目の編集~
Djangoを本格的に開発していくためにはsettings.pyの設定を編集していくことが欠かせませんので、これから開発にあたって必要となってくる設定をここで初めにしておきます
プロジェクト構成に合わせて空フォルダを作成する
以下のようなフォルダ構成のフォルダとファイルを作成する
matchingappapi(プロジェクトルート)
┗ secrets - オープンソースなどでソースコードを外部公開する際に秘匿にしておく情報を扱う
┗ .env.dev
┗ .env.prod
┗ media - 開発環境のみでクライアントとの画像処理に使用する
┗ .gitignore - githubにアップロードしないファイルを定義する
シークレット情報の環境変数化
上記で作成した.envファイルに環境変数を記載する
.env.dev(開発環境)
SECRET_KEY=django-xxxxxxxxxxxxxx DEBUG=True ALLOWED_HOSTS=* CORS_ORIGIN_WHITELIST=http://localhost:3000 DATABASE_URL=sqlite:///db.sqlite3
.env.prod(本番環境 - デプロイする場合)
SECRET_KEY=django-xxxxxxxxxxxxxx DEBUG=False ALLOWED_HOSTS=[特定のホスト] CORS_ORIGIN_WHITELIST=[特定のホスト] DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/NAME
なおこの時環境変数に格納する値はクオテーションなしで登録する
DjangoはデフォルトでSQLiteを使用しているため、.env.devではSQLiteを使用することにする
また本番環境ではPostgresを使用するため.env.prodではPostgressを使用する設定とする
大文字になっている箇所は任意の名前を入力する
ちなみに余談ですがHerokuではデプロイした先でデフォルトで環境変数にDATABASE_URL=Postgresのパスが設定されているみたいです
setting.pyの編集
アプリケーション適用
まずはプロジェクトにアプリケーション(basicapi)を適用します
settings.py
INSTALLED_APPS = [
'basicapi.apps.BasicapiConfig',
]
環境変数適用
次にsetting.pyに先ほど.envに外部ファイル化した環境変数を適用していきます
setting.py
import environ env = environ.Env() root = environ.Path(os.path.join(BASE_DIR, 'secrets')) # 本番環境 # env.read_env(root('.env.prod')) # 開発環境 env.read_env(root('.env.dev')) SECRET_KEY = env.str('SECRET_KEY') DEBUG = env.bool('DEBUG') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[])
rootに関してはsecretsフォルダを作成したことで変数化しており、プロジェクト直下に配置する構成の場合は記載する必要はなくなる
なお次のような書き方もある
root = environ.Path(BASE_DIR / 'secrets')
パッケージ適用
次に最初にインストールしたpythonパッケージをこれから作るアプリに適用していきます
setting.py
import os from datetime import timedelta INSTALLED_APPS = [ 'rest_framework', 'corsheaders', 'djoser', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ] CORS_ORIGIN_WHITELIST = env.list('CORS_ORIGIN_WHITELIST', default=[])
CORS_ORIGIN_WHITELIST はフロントエンドからのアクセスを許可するためのホワイトリスト設定で、SIMPLE_JWT は認証トークンに使用するJWTの設定です
アクセス制限の適用
次に、バックエンドをREST API化したときのセキュリティの設定としてAPIの利用はデフォルトで認証された(ログイン中)ユーザーのみに制限します
また認証にはJWTを利用することにしており、Tokenの有効期限を1日に設定しています
settings.py
REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], } SIMPLE_JWT = { 'AUTH_HEADER_TYPES': ('JWT',), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=1440) }
Djangoの設計自体は認証に関する権限に関してはViewに各画面毎に定義することになっているが、ベストプラクティスとしては基本となる認証ロジックに関してはsetting.pyに記述して差分をViewで適用する方法の方が良い実装だとされている模様で、本アプリではベストプラクティスに沿った実装を行おうと思います
DB設定
次に、Databaseの設定を行います
デフォルト(ローカル)ではsqliteを使用して、本番環境ではPOSTGRESを使用する構成にします
django-environのパッケージを使用すると環境変数でDBも取り扱えるようになる
環境変数にDATABASE_URLが記載されている場合そちらを優先して使用され、DATABASE_URLが無記載の場合はenv.db()がDjangoデフォルトのSQLiteを設定してくれます
settings.pyを以下のように書き換えます
settings.py
# DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.sqlite3', # 'NAME': BASE_DIR / 'db.sqlite3', # } # } DATABASES = { 'default': env.db(), }
カスタムユーザー適用準備
次に、今後Djangoで扱うユーザーはカスタムユーザーを使用していくので、ユーザーの設定を記載します
settings.py
AUTH_USER_MODEL = 'basicapi.User'
ロケーション変更
次に、ロケーションを日本に変更します
settings.py
LANGUAGE_CODE = 'ja' TIME_ZONE = 'Asia/Tokyo'
StaticRootの設定
次に、静的ファイルの配信最適化をする設定を行います
本番環境(Heroku)にデプロイする際にばらばらに配置されている静的ファイルを指定した一つのフォルダにまとめてくれます
settings.py
STATIC_ROOT = str(BASE_DIR / 'staticfiles')
Mediaファイルの取り扱い設定
次に、静的ファイルを取り扱えるようにmediaの設定をします
こちらも環境変数化し、本番環境と開発環境を切り分ける必要があるが後々行うこととします
settings.py
# 開発環境 MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/'
またmediaの設定はurls.pyも編集が必要となるので、忘れないうちに以下のコードを追加しておきます
urls.py
from django.conf.urls.static import static from django.conf import settings if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
余談として、デフォルトで記述されているTEMPLATES は本アプリでは使用しないためsetting.pyから削除してもよいはずです
URLの設定
プロジェクト側urls.pyの設定
後でもいいですが、ここでルーティングのガワを決めておきます
matchingappapi/urls.py
from django.conf.urls import include urlpatterns = [ path('api/', include('basicapi.urls')), path('authen/', include('djoser.urls.jwt')), ]
アプリ側urls.pyの設定
basicapi直下にurls.pyを作成してアプリのルーティングのガワを記載しておきます
basicapi/urls.py
from rest_framework.routers import DefaultRouter from django.urls import path from django.conf.urls import include router = DefaultRouter() app_name = 'basicapi' urlpatterns = [ path('', include(router.urls)), ]
.gitignoreの編集
さて上記の設定作業では外部に流出したくないファイルを作成したので、そのファイルたちはGitHubにアップロードしないように設定したいので、.gitignoreを編集していきます
現時点では以下のあたりを記述しておく
.gitignore
basicapi/__pycache__/ basicapi/migrations/__pycache__/ matchingappapi/__pycache__/ media/ secrets/ db.sqlite3
こちらで初期設定はあらかた完了です
Modelの実装
それでは本題です
モデルを実装していきましょう
カスタムユーザーの実装
まずはカスタムユーザーを実装していきます
Djangoのドキュメントによると最初のMigrationでカスタムユーザーを使用しないとデフォルトユーザーを使用することになってしまい、さらにドキュメントにはカスタムユーザーの使用を推奨すると記載されているので、実質Djangoの初期作業となっています
カスタムユーザーの作り方としてはAbstractUserを継承する方法と、AbstractBaseUserを継承する方法の2種類がある
- AbstractUser: すでに存在するフィールドをそのまま流用して、フィールドの増減を行うことができる
ただし、メールアドレスをログインIDにするなどのことができないため、限定的な利用にとどまる
- AbstractBaseUser: Userモデルをほとんどゼロベースから作成することが可能
自由度が高くこちらをカスタムユーザーとするのが一般的で本アプリでもAbstractBaseUserでカスタム実装していきます
それではアプリのモデルを編集します
basicapi/models.pyを編集します
ユーザーマネージャーの実装
AbstractBaseUserを使用する場合はデータ挿入取得更新処理をするマネージャークラスを作成する必要があるのでマネージャークラスをまずは作ります
Djangoのデフォルトではユーザー名がログインに使用されますが、本アプリのカスタムユーザーはemailをユーザー名として利用するようにします
models.py
from django.contrib.auth.models import BaseUserManager class UserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError('Users must have an email address') user = self.model(email=self.normalize_email(email), **extra_fields) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, email, password): user = self.create_user(email, password) user.is_staff = True user.is_superuser = True user.save(using=self._db) return user
ユーザークラスの実装
ユーザーマネージャーが作成できれば次はユーザークラスを作成します
models.pyにユーザークラスを追記します
models.py
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin import uuid class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) email = models.EmailField(max_length=255, unique=True) username = models.CharField(max_length=255, blank=True) is_active = models.BooleanField(default=False) is_staff = models.BooleanField(default=False) objects = UserManager() USERNAME_FIELD = 'email' def __str__(self): return self.email
idにはuuidを使用しています
is_staffは管理者画面にアクセスできる権限を付与してしまうので、基本デフォルトでFalseにします
またマッチングアプリの利用者は年齢確認をしなければならないという制約があるので、アカウントの有効化はクレジットカード決済が有効だったユーザーのみに限定したいのでデフォルトでis_activeをFalseにします
アカウントの有効化機能はREST APIを作成する中で作成していきます
Profileクラスの実装
カスタムユーザーを作成する場合は通常セットで作成されるクラスです
nicknameなどの新たなフィールドをユーザークラスに追加したいが、ユーザークラスを煩雑にしたくないケースに頻繁に利用されます
カスタムユーザーを作成するときにはProfileクラスをセットで作成するのがデファクトスタンダードになっているといっても遜色ないかと思います
Userと1対1の関係をとるのでOneToOneFieldを利用する
マッチングアプリは性別や年齢が大切になってくるので必要な最低限の項目を追加したものをまずは作ります
models.py
from django.conf import settings from django.core.validators import MinValueValidator, MaxValueValidator class Profile(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE, related_name='profile') is_kyc = models.BooleanField("本人確認", default=False) nickname = models.CharField("ニックネーム", max_length=20) created_at = models.DateTimeField("登録日時", auto_now_add=True) updated_at = models.DateTimeField("更新日時", auto_now=True, blank=True, null=True) age = models.PositiveSmallIntegerField( "年齢", validators=[MinValueValidator(18, '18歳未満は登録できません'), MaxValueValidator(100, '100歳を超えて登録はできません')]) SEX = [ ('male', '男性'), ('female', '女性'), ] sex = models.CharField("性別", max_length=16, choices=SEX) introduction = models.TextField("自己紹介", max_length=1000) def __str__(self): return self.nickname
次に既存のアプリでは他の項目もたくさんあるので、主にPairsを参考にその他のフィールドも追加していきます
プロフィールクラスにさらに項目を追加した結果が以下です
一気にコードが増えました
models.py
from datetime import datetime, timedelta def top_image_upload_path(instance, filename): ext = filename.split('.')[-1] return '/'.join(['images', 'top_image', f'{instance.user.id}{instance.nickname}.{ext}']) class Profile(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE) """ Profile Fields """ is_special = models.BooleanField(verbose_name="優良会員", default=False) is_kyc = models.BooleanField(verbose_name="本人確認", default=False) top_image = models.ImageField( verbose_name="トップ画像", upload_to=top_image_upload_path, blank=True, null=True) nickname = models.CharField(verbose_name="ニックネーム", max_length=20) created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True) updated_at = models.DateTimeField(verbose_name="更新日時", auto_now=True, blank=True, null=True) """ Physical """ age = models.PositiveSmallIntegerField( verbose_name="年齢", validators=[MinValueValidator(18, '18歳未満は登録できません'), MaxValueValidator(100, '100歳を超えて登録はできません')]) SEX = [ ('male', '男性'), ('female', '女性'), ] sex = models.CharField("性別", max_length=16, choices=SEX) height = models.PositiveSmallIntegerField( verbose_name="身長", blank=True, null=True, validators=[MinValueValidator(140, '140cm以上で入力してください'), MaxValueValidator(200, '200cm以下で入力してください')]) """ Environment """ LOCATION = [ ('hokkaido', '北海道'), ('tohoku', '東北'), ('kanto', '関東'), ('hokuriku', '北陸'), ('chubu', '中部'), ('kansai', '関西'), ('chugoku', '中国'), ('shikoku', '四国'), ('kyushu', '九州'), ] location = models.CharField(verbose_name="居住エリア", max_length=32, choices=LOCATION, blank=True, null=True) work = models.CharField(verbose_name="仕事", max_length=20, blank=True, null=True) revenue = models.PositiveSmallIntegerField(verbose_name="収入", blank=True, null=True) GRADUATION = [ ('junior_high_school', '中卒'), ('high_school', '高卒'), ('trade_school', '短大・専門学校卒'), ('university', '大卒'), ('grad_school', '大学院卒'), ] graduation = models.CharField( verbose_name="学歴", max_length=32, choices=GRADUATION, blank=True, null=True) """ Appealing Point """ hobby = models.CharField( verbose_name="趣味", max_length=32, blank=True, null=True) PASSION = [ ('hurry', '今すぐにでも'), ('speedy', '1年以内に'), ('slowly', 'ゆっくり考えたい'), ('no_marriage', '結婚する気はない'), ] passion = models.CharField( verbose_name="結婚に対する熱意", max_length=32, choices=PASSION, blank=True, null=True, default='slowly') tweet = models.CharField(verbose_name="つぶやき", max_length=8, blank=True, null=True) introduction = models.TextField(verbose_name="自己紹介", max_length=1000, blank=True, null=True) """ Assesment Fields """ send_favorite = models.PositiveIntegerField( verbose_name="送ったいいね数", blank=True, null=True, default=0) receive_favorite = models.PositiveIntegerField( verbose_name="もらったいいね数", blank=True, null=True, default=0) stock_favorite = models.PositiveIntegerField( verbose_name="いいね残数", blank=True, null=True, default=1000) class Meta: ordering = ['-created_at'] def from_last_login(self): now_aware = datetime.now().astimezone() if self.user.last_login is None: return "ログイン歴なし" login_time: datetime = self.user.last_login if now_aware <= login_time + timedelta(days=1): return "24時間以内" elif now_aware <= login_time + timedelta(days=2): return "2日以内" elif now_aware <= login_time + timedelta(days=3): return "3日以内" elif now_aware <= login_time + timedelta(days=7): return "1週間以内" else: return "1週間以上" def __str__(self): return self.nickname
verbose_nameはフィールドの詳細名です
validatorsはバリデータを設定できる項目で最小値や最大値などの制約を加えることができます
choicesは選択式のフィールドに対応する項目です
選択肢に対応する値はタプルの配列で格納します
DB上ではKeyが格納されコード上では辞書型として提供されます
最終ログイン日を教えてくれるサービスがあるので本アプリもそれにならいます
Userクラスのコードの見通しはよくしておきたいのでProfileクラスにメソッドを定義します
もしかしたらアンチパターンかもしれませんが......
またDjangoのモデルのフィールドはキャメルケースと迷いましたがDjangoに書いたフィールド名がDBのフィールド名にそのまま記載されたはずなので本アプリではスネークケースで書いています
デファクトスタンダードなのはどっちなのか正直わかっていません
Matchingクラスの実装
次にマッチングクラスを実装します
いいねを送った人ともらった人を格納するテーブルです
マッチングの実装については、いいねをもらった側のユーザーが承認(approvedをTrue)すると同時に相手側にいいねを送り返して双方向のいいねが成立した時点でマッチングとします
マッチング後、すなわちapprovedがTrueになっている場合にメッセージをやり取りできるような実装とします
またちなみにDjangoでは複合主キーはサポートされていないですが、フィールド同士の組み合わせ制約をunique_togetherで付与することができ、マッチングの重複を避けることができます
models.py
class Matching(models.Model): approaching = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='approaching', on_delete=models.CASCADE ) approached = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='approached', on_delete=models.CASCADE ) approved = models.BooleanField(verbose_name="マッチング許可", default=False) created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True) class Meta: unique_together = (('approaching', 'approached'),) def __str__(self): return str(self.approaching) + ' like to ' + str(self.approached)
最初に設計したER図とずれがあるのは気にしないで下さい
実装が正です
DM(ダイレクトメッセージ)クラスの実装
最後にDirectMessageクラスの実装を行います
マッチングが成立している(MatchingクラスのapprovedがTrueになっている)ユーザー同士のメッセージが格納されます
余力がある人は画像データなどのマルチメディアのデータを送れるような実装にしてもよいかもしれませんが、このアプリではテキストデータのみを扱えることとします
models.py
class DirectMessage(models.Model): sender = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='sender', on_delete=models.CASCADE ) receiver = models.ForeignKey( settings.AUTH_USER_MODEL, related_name='receiver', on_delete=models.CASCADE ) message = models.CharField(verbose_name="メッセージ", max_length=200) created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True) def __str__(self): return str(self.sender) + ' --- send to ---> ' + str(self.receiver)
他にも作成したほうが良いモデルクラスは多分にありますが、今回はここまでの実装とします
参考にしているPairsにはコミュニティ機能があるので余力があればコミュニティクラスを作ったりタグクラスを作ったりしてもよいかもしれません
管理者Adminサイトの整備
管理者画面を扱いたい場合はアプリフォルダ(basicapi)にあるadmin.pyを編集していく必要があります
カスタムユーザーのモデルクラスを管理画面で扱えるようにする
管理者画面にカスタムユーザーを追加
basicapi/admin.pyを以下のように編集してきます
admin.py
from django.contrib import admin from .models import User from django.contrib.auth.admin import UserAdmin as BaseUserAdmin class UserAdmin(BaseUserAdmin): ordering = ('id',) list_display = ('email', 'password') fieldsets = ( (None, {'fields': ('email', 'password')}), ('Personal Information', {'fields': ('username',)}), ( 'Permissions', { 'fields': ( 'is_active', 'is_staff', 'is_superuser', ) } ), ('Important dates', {'fields': ('last_login',)}), ) add_fieldsets = ( (None, { 'classes': ('wide',), 'fields': ('email', 'password1', 'password2'), }), ) admin.site.register(User, UserAdmin)
Profileクラスをカスタムユーザーの詳細画面に挿入する
Inclineを利用するとテーブルが分かれているクラスを同一画面で見ることができて管理画面が利用しやすくなるのでUserにProfileクラスを挿入していきます
Inclineの行をUserAdminに追加します
admin.py
from .models import User, Profile class UserAdmin(BaseUserAdmin): # ...... inlines = (ProfileInline,)
実装したモデルクラスを管理画面で扱えるようにする
同様にadmin.pyを編集してユーザー以外のデータも見られるようにします
Prolileを管理者画面に追加
admin.py
from .models import Profile class ProfileAdmin(admin.ModelAdmin): ordering = ('-created_at',) list_display = ('__str__', 'user', 'age', 'sex', 'tweet', 'created_at', 'from_last_login') admin.site.register(Profile, ProfileAdmin)
Matchingを管理者画面に追加
admin.py
from .models import Matching class MatchingAdmin(admin.ModelAdmin): ordering = ('-created_at',) list_display = ('__str__', 'approved', 'created_at') admin.site.register(Matching, MatchingAdmin)
DirectMessageを管理者画面に追加
admin.py
from .models import DirectMessage class DirectMessageAdmin(admin.ModelAdmin): ordering = ('-created_at',) list_display = ('__str__', 'message', 'created_at') admin.site.register(DirectMessage, DirectMessageAdmin)
管理者ユーザー(Superuser)の作成
管理者画面にアクセスできるユーザーを作成しておきましょう
Djangoのデフォルトユーザーではusernameが作成時に必要ですがカスタムユーザーが適用されているとemailの設定が必要になっていることを確認できるはずです
py manage.py createsuperuser
管理画面の動作確認
管理者ユーザーを作成したらサーバーを起動して管理者画面にアクセスしてみましょう
http://127.0.0.1:8000/admin
emailとパスワードでログインできることを確認します
また実装したモデルが管理画面に表示されて追加などの各種CRUD操作ができることを確認します
またユーザーのパスワードがハッシュ化されていることやプロフィールがインラインで表示されていることも確認しておきます
これらが一通り確認できれば動作確認としては完了です
次回予告
さて、次はSerializersとViewsの実装を行っていよいよRestAPIを作成していきますが、長くなりましたのでここでいったん区切らせてもらいます
次の記事
今後の課題
今回年齢を格納するフィールドはProfileに持たせましたが、年齢は年を経るごとに更新されていくので生年月日の項目が必要であるのと、ユーザー新規作成時に年齢を入力してもらいたいという観点から、ユーザークラスに新規登録時年齢registered_ageと生年月日のフィールドを持っておいた方が良いですね
プロフィールは表示年齢として生年月日から自動計算にする実装にした方がいいですね