防止Laravel中同时发生模型突变的最佳方法

It's probably one of the most common issues with concurrent users working in the same app.

User A fetches a model from the database, which is shown to the user on the screen. He performs some actions, and updates values of that object.

At a certain time, it saves the model. Little did user A know that between the time that he fetched the model, and saves the modified model, user B already fetched the same model, and submitted his changes to the database. Result: the changes of user B are lost.

I've solved this same problem many times already, and usually do it like this:

1/ add an attribute to the model $version

2/ upon creating a new model, we set $version = 1

3/ when fetching the model, the version attribute is obviously also fetched.

4/ when saving modified model, I only save ... where id = $model->id AND WHERE version = $model->version At the same time, I'm updating $version = $version + 1

When that update throws a "no records updated" from the database, I search for the model using id only; if I can fetch that, i know that the model existed, but that the version does no longer match. I then raise an exception to the user (saying 'someone or something already modified the model')

I believe it's the only correct way to make sure that you are always updating the latest version of the object. You can obviously also use the updated_at attribute to compare that you still have the same version of the object, but theoretically another user may have updated it nevertheless.

Now I'm struggling with how to set this logic up in Laravel. I want to create a trait that I can use on a model that basically does what I describe above, but I'm struggling with what I should do really.

I know I can override protected function setKeysForSaveQuery($query) to have it update where id = $this->id and version = $this->version but when I do that, and laravel does not update anything, I'm not getting an exception that allows me to determine that the update failed because the version didn't match...

I want to avoid having to write code where I encapsulate the save in a transaction, and have to validate the versions every time I save a model; looking for a way to put this in my base models by including a trait, so that it works automagically.

Any input would be much appreciated.

-- added example:

Imagine the following:

$modelcopy1 = App\Model::find(1);  // version = 1
$modelcopy2 = App\Model::find(1);  // version = 1

$modelcopy1->save(); // this should update version in database to 2
                     // also $modelcopy1->version should now hold 2

$modelcopy2->save(); // this should now throw an exception
                     // because copy 1 still references version 1
                     // database version is already at 2

So basically, I need logic in the ->save() that validates that the model value for the version attribute matches the current database version, and I need a sync so that when the update is done, the model version attribute is updated to the most recent version.

The save should look like this imo:

UPDATE Model
SET version = version + 1, attr1 = :attr1, attr2 = :attr2, ...
WHERE version = $model->version
AND id = $model->id

IF UPDATE successful --> make sure that $model->version is updated to new version value

IF UPDATE is unsuccessful THEN SELECT * FROM Model WHERE id = $model->id

IF model found, then THROW ERROR 'model was already updated by someone else' ELSE THROW ERROR 'model does not exist'

I hope this makes sense. In my opinion such a locking preventing mechanism should be part of any 'adult' framework, but it seems very difficult to tie it into the Laravel framework...

So you want to write a trait that will perform the check to see if the model has been updated since the current one was received, and throw back something you can hook onto to notify the user? I would look into making a base model that all of your models extend, and using model events in that base model. You may not even need a trait if you just have all models extend the base model. See below sample model event method:

protected static function boot(){
    parent::boot();
    static::saving(function($thisModel){
        $changes = Model::where('version', $thisModel->version)
            ->where('id', $thisModel->id)->first()
        /** if changes->version matches this one, good, if not, set some
         *  property on the model that can be checked.   
         */
    }
}

This will happen as the model is saving, and if you return false; at any point in the method, the save will fail and the model will not be saved.

If you still want to use a trait, be sure that the boot method follows this format: bootTraitName(), or it won't boot on the implementing classes' boot. This is a tricky question with a lot of different angles, but I hope I at least gave you something to consider.

Edit: I've never used model events on a base model, so I'm not sure if the parent::boot(); is necessary. May have to test.