LoginSignup
0
0

More than 1 year has passed since last update.

LaravelのAPI resourceで1対1のモデルを相互に呼びあったらハングアウトした

Posted at

概要

Laravelのドキュメントに例としてあげられているUserモデルとPhoneモデル。
https://readouble.com/laravel/8.x/ja/eloquent-relationships.html#one-to-one

ユーザー情報取得時には、それに紐づくPhoneモデルの情報を、
電話情報取得時には、それに紐づくUserモデルの情報を付け加えた形で返す
シンプルなAPIをLaravelのAPI resourceを使って作りたかった。

期待するレスポンス

GET /api/users/1

{
  "data": {
    "id": 1,
    "name": "test",
    "phone": {
      "id": 1,
      "number": "xxx-xxxx-xxxx"
    }
  }
}

GET /api/phones/1

{
  "data": {
    "id": 1,
    "number": "xxx-xxxx-xxxx",
    "user": {
      "id": 1,
      "name": "test"
    }
  }
}

ソース

Userモデル

class User extends Model
{
  use HasFactory;

  public function phone()
  {
    return $this->hasOne(Phone::class);
  }
}

Phoneモデル

class Phone extends Model
{
  use HasFactory;

  public function user()
  {
    return $this->belongsTo(User::class);
  }
}

Userリソース

class UserResource extends JsonResource
{
  public function toArray($request)
  {
    return [
      'id' => $this->id,
      'name' => $this->name,
      'phone' => new PhoneResource($this->phone),
    ];
  }
}

Phoneリソース

class PhoneResource extends JsonResource
{
  public function toArray($request)
  {
    return [
      'id' => $this->id,
      'number' => $this->number,
      'user' => new UserResource($this->user),
    ];
  }
}

Userコントローラー

class UserController extends Controller
{
  public function show(User $user)
  {
    return new UserResource($user);
  }
}

Phoneコントローラー

class PhoneController extends Controller
{
  public function show(Phone $phone)
  {
    return new PhoneResource($phone);
  }
}

APIルート

Route::apiResource('users', UserController::class);
Route::apiResource('phone', PhoneController::class);

結果と問題点

上記のソースではPostmanからAPIを呼び出すとハングアウトする。

GET http://localhost/api/users/1
Error: socket hang up

理由はAPI resouceの定義でUserモデルとPhoneモデルのリレーションの
読み込みが無限ループしていること。

例としてUserモデルを挙げるが、今回はルーティング定義時にモデルクラスを
タイプヒントすることでモデルを自動取得する方法を採用していた。

Route::apiResource('users', UserController::class);

これによりコントローラーのアクションメソッドでは、指定されたIDに紐づくモデルが
既に取得された状態となるが

Userコントローラー

public function show(User $user)
{
  return new UserResource($user);
}

実はこの時点ではUserモデルに紐づくPhoneモデルは取得されていない。
なぜならLaravelのリレーションは遅延ロードであるため。

このことは公式ドキュメントにも記載されている。
https://readouble.com/laravel/8.x/ja/eloquent-relationships.html#eager-loading

プロパティとしてEloquentリレーションへアクセスすると、関連するモデルは「遅延読み込み」されます。
つまりこれは、最初にプロパティへアクセスするまで、リレーションデータが実際にロードされないことを意味します。

なので実際にUserモデルに紐づくPhoneモデルが取得されるのは、UserリソースでのPhoneResource生成時。
これにより、以下のコメントに振っている番号のような順で呼び出しあった結果、無限ループしている。

ユーザー取得のAPIを呼び出した場合(一部抜粋)

// UserResource
return [
  'id' => $this->id,
  'name' => $this->name,
  // 1. Phoneモデルの取得
  // 3. Phoneモデルの取得
  // 5. Phoneモデルの取得.....
  'phone' => new PhoneResource($this->phone),
];

// PhoneResource
return [
  'id' => $this->id,
  'number' => $this->number,
  // 2. Userモデルの取得
  // 4. Userモデルの取得
  // 6. Userモデルの取得.....
  'user' => new UserResource($this->user),
];

解消方法

この無限ループを解消するためには、eager loadを使う。

eager loadに関して

eager loadの内容に関しては公式ドキュメントを参照。
つまり都度読み込みではなく先にリレーションまで読み込んでしまう方法。
https://readouble.com/laravel/8.x/ja/eloquent-relationships.html#eager-loading

モデル取得方法の変更

これを行うにはモデルを取得する部分に手を入れないと行けないので、
仕方なくルーティング時にタイプヒントでモデルを自動取得する方法から、
手動で取得する方法に切り替える。
※他にやり方があるのかもしれないがみつからない。。。

APIルート

// Route::apiResource('users', UserController::class);
Route::get('/users/{id}', [UserController::class, 'show']);
// Route::apiResource('phone', PhoneController::class);
Route::get('/phones/{id}', [PhoneController::class, 'show]);

それに伴いコントローラーも変更
例としてUserコントローラーのみを挙げるがPhoneコントローラーの方も変更する。

// public function show(User $user)
public function show (int $id)
{
  // これがeager load. withで指定されたリレーションをこの時点で読み込む.
  $user = User::with('phone')->get()->find($id);
  return new UserResource($user);
}

whenLoadedを使う

さらにAPI resourceにも手を加える。
$this->phoneではなくwithLoaded('phone')を使う。
このメソッドは既にリレーションがロードされている場合に限りリレーションを返し、
ロードされていない場合はそのプロパティごとレスポンスから削る。
つまり、リレーションを返すか否かをeager loadされているかに委ねる。

例としてUserリソースのみを挙げるがPhoneリソースの方も変更する。

Userリソース

class UserResource extends JsonResource
{
  public function toArray($request)
  {
    return [
      'id' => $this->id,
      'name' => $this->name,
      // 'phone' => new PhoneResource($this->phone),
      'phone' => new PhoneResource($this->whenLoaded('user')),
    ];
  }
}

これによりリレーションはコントローラでeager loadにより取得する経路だけとなり、
API resourceからリレーションを取得することがなくなるため、無限ループが解消する。

参考

https://laracasts.com/discuss/channels/laravel/api-resource-infinite-loop
https://readouble.com/laravel/8.x/ja/eloquent-relationships.html#eager-loading

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0