はじめに
BaaS(Backend as a Service)を利用して作成したアプリを、学習のために「フロントとバックを疎結合にして、自分でバックエンドを構築してみたい」と思い、Ruby on Railsへの書き換えに挑戦しました。
移行にあたって直面した「3つの壁」とその解決策を、技術ドキュメントとしてまとめます。
壁①:サーバーの「スリープ問題」をどう叩き起こすか(スリープ対策)
バックエンドのホスティングには Render の無料プランを採用しました。しかし、Renderには「15分間リクエストがないとスリープする」という仕様があります。
スリープ状態でフロント(Vercel/Angular)を操作すると、起動まで30秒〜1分ほど待たされ、ユーザー体験が著しく低下してしまいます。
そこで、ユーザーが具体的な操作を始める前、アプリを開いた瞬間にバックグラウンドでリクエストを飛ばし、サーバーを叩き起こす仕組みを導入しました。
1. Rails側に軽量な「Health Check」を用意
DBアクセスを介さない、最も負荷の低いエンドポイントを作成します。
DBへのクエリが走らないため、サーバー本体が立ち上がった瞬間に 200 OK を返せます。
# config/routes.rb
namespace :api do
namespace :v2 do
get 'health_check', to: ->(env) { [200, {}, ['ok']] }
# ... 他のルート
end
end
2. Angularの起動時にリクエストを飛ばす
ユーザーが「お気に入り登録」などの操作をする前に、アプリが開いた瞬間にバックグラウンドで1発リクエストを飛ばします。
app.component.ts の ngOnInit で実行します。
// app.component.ts
export class AppComponent implements OnInit {
constructor(private http: HttpClient) {}
ngOnInit() {
// レスポンスを待つ必要はないので、単に1発叩くだけ
// Rails側に GET /api/health_check などを作っておくと理想的
this.http.get('https://your-api.com/api/health_check')
.subscribe({
next: () => console.log('Backend is awake!'),
error: () => console.log('Backend is waking up...')
});
}
}
これでユーザーがアプリを起動させると、サーバーが勝手に起きるという体験を作れます。
壁②:JWT(JSON Web Token)による安全な認証設計
自前サーバーにする以上、認証も自分で設計する必要があります。今回は JWT を採用し、Railsの CurrentAttributes を使って「今誰がアクセスしているか」を管理しました。
なぜJWT?
今回はAngularとRailsを別々のドメイン(VercelとRender)で動かす「完全な疎結合」を目指しました。Cookieベースの認証だとクロスドメインの設定が複雑になりがちですが、JWTであればHTTPヘッダーにトークンを載せるだけで済むため、アーキテクチャをシンプルに保てるためJWTを採用しました。
またJWTによるトークン認証は、Cookieを扱いにくいモバイルアプリとの相性が非常に良く、拡張性も高いこともポイントです。
JWTとCookie(セッション認証)の詳しい比較については、以下の記事などが非常に参考になります。
仕組みの全体像
1. ログイン時: Railsが authToken を発行し、Angularへ送る。
2. 保存: Angularがそれを localStorage に保管する。
3. リクエスト: Angularがヘッダーにカギ(トークン)を載せて送信。
4. 認証: Railsが解読し、Current.user にユーザーをセット。
実装のポイント
1. Rails:UserモデルとJWT生成
(app/models/user.rb) まず、パスワードのハッシュ化と、ログイン時に「カギ(トークン)」を発行するメソッドを用意します。
キー名を authToken にしています。
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :fullname, presence: true
# JWTトークンを生成するメソッド
def generate_jwt_token
payload = { user_id: id }
# 署名には RAILS_MASTER_KEY (または JWT_SECRET_KEY) が使われる
JwtService.encode(payload)
end
end
2. Rails:ログイン処理(SessionsController)
(app/controllers/api/v2/sessions_controller.rb) ユーザーを検証し、Angular側で扱いやすい authToken というキー名でトークンを返却します。
class Api::V2::SessionsController < ApplicationController
skip_before_action :require_authentication, only: [:create]
def create
if user = User.authenticate_by(email: params[:email], password: params[:password])
token = user.generate_jwt_token
render json: {
message: "ログインしました",
user: user.as_json(only: [:id, :fullname, :email]),
authToken: token, # Angular側の変数名に合わせる
token_type: "Bearer"
}, status: :created
else
render json: { error: "メールアドレスまたはパスワードが正しくありません" }, status: :unauthorized
end
end
end
3. Rails:共通の認証ガード(ApplicationController)(app/controllers/application_controller.rb) すべてのリクエストの前にトークンを解読し、Current.user という「どこからでもアクセスできる箱」にユーザーを入れます。
class ApplicationController < ActionController::API
before_action :require_authentication
private
def require_authentication
token = request.headers["Authorization"]&.split(" ")&.last
decoded = JwtService.decode(token) if token
if decoded && user = User.find_by(id: decoded[:user_id])
Current.user = user # 「Currentという箱」にユーザーを入れる
else
render json: { error: "認証が必要です" }, status: :unauthorized
end
end
end
これにより、各コントローラーでは current_user を引数で回す必要がなくなり、以下のようにスマートに記述できます。
@favorite_item = Current.user.favorite_items.new(favorite_item_params)
4. Angular:トークンのlocalStorage への保存(AuthService)
(src/app/services/auth.service.ts) Railsから届いた authToken を、ブラウザの「財布(localStorage)」に大切に保管します。
login(credentials: any) {
return this.http.post<AuthResponse>(`${this.apiUrl}/session`, credentials).pipe(
tap(response => {
if (this.isBrowser) {
const token = response.authToken || response.token; // Railsから届いたトークン
if (token) {
localStorage.setItem('authToken', token);
console.log('✅ ログイン成功、トークンを保存しました');
}
}
})
);
}
5. Angular:カギを自動で載せる(HttpInterceptor)
リクエストのたびに手動でヘッダーを書くのは大変です。Angularの HttpInterceptor を使うと、すべての通信に自動でトークンを忍ばせることができます。
// src/app/interceptors/auth.interceptor.ts
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { Observable } from 'rxjs';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getToken();
// トークンがあれば、ヘッダーに 'Authorization: Bearer <token>' を追加する
if (token) {
const cloned = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next.handle(cloned);
}
return next.handle(req);
}
}
壁③:フロントとバックの「命名規則」のズレ
Angular(TypeScript)は camelCase、Rails(Ruby)は snake_case が標準です。これをそのまま通信させると、バリデーションエラーの原因になります。
APIとの通信時に、データのキー名を相互に変換するレイヤーを用意しました。
// 取得時:API(snake_case) -> Angular(camelCase)
private transformFromApi(data: any): FavoriteItem {
return {
id: data.id,
itemName: data.item_name,
brandName: data.brand_name,
// ...
};
}
// 保存時:Angular(camelCase) -> API(snake_case)
private transformToApi(item: FavoriteItem): any {
return {
item_name: item.itemName,
brand_name: item.brandName,
// ...
};
}
このメソッドを pipe(map(...)) の中で通すことで、フロントのコード内ではキャメルケースを保ちつつ、バックエンドとも正しく会話できるようになりました。
// 全お気に入りアイテムを取得
getFavoriteItems(): Observable<FavoriteItem[]> {
return this.http.get<any[]>(`${this.apiUrl}/favorite_items`, {
headers: { 'Content-Type': 'application/json' }
}).pipe(
map(items => items.map(item => this.transformFromApi(item)))
);
}
おわりに
BaaSを使えば一瞬で終わることも、自前で実装すると多くの「壁」にぶつかりました。ですが、自前でバックエンドを構築し、サーバーサイドのロジックを一つひとつ実装したことで、システム全体を自分でコントロールできている実感があり、非常に楽しく感じました。
この経験を糧に、これからも「フロントとバックの両面から最適な設計」を心がけていきたいと思います。