简体   繁体   中英

How to safely use UniqueEntity (on sites with more than one simultaneous user)

Can someone smart can share the design pattern they use to avoid this basic and common concurrency problem in Doctrine\\Symfony?

Scenario: Each User must have a unique username.

Failed Solution:

Why It Fails: Between validating and persisting the User, the username may be taken by another User. If so, Doctrine throws a UniqueConstraintViolationException when it tries to persist the newest User.

Here is what my following answer does:

  • It displays errors gracefully to the user if a constraint violation occurs, like if the validator handled it,

  • It prevents database updates that are not "protected" to break your controller logic (for example with an UPDATE statement or a form submission with "unprotected" controllers),

  • It is a database-independant solution.

Here is the code, with explanations on comments:

<?php

// ...

use Doctrine\DBAL\Exception\ConstraintViolationException;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\Extension\Validator\ViolationMapper\ViolationMapper;

// ...

public function indexAction(Request $request)
{
    $task = new Task();

    $form = $this->createFormBuilder($task)
        ->add('name', TextType::class)
        ->add('save', SubmitType::class, array('label' => 'Create Task'))
        ->getForm();

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $task = $form->getData();
        $em = $this->getDoctrine()->getManager();
        $em->persist($task);

        try {
            $em->flush();

            // Everything went well, do whatever you're supposed to.
            return $this->redirectToRoute('task_success');
        } catch (ConstraintViolationException $e) {
            // Reopen the entity manager so the validator can do jobs
            // that needs to be performed with the database (in example:
            // unique constraint checks)
            $em = $em->create($em->getConnection(), $em->getConfiguration());

            // Revalidate the form to see if the validator knows what
            // has thrown this constraint violation exception.
           $violations = $this->get('validator')->validate($form);

            if (empty($violations)) {
                // The validator didn't see anything wrong...
                // It can happens if you have a constraint on your table,
                // but didn't add a similar validation constraint.

                // Add an error at the root of the form.
                $form->add(new FormError('Unexpected error, please retry.'));
            } else {
                // Add errors to the form with the ViolationMapper.
                // The ViolationMapper will links error with its
                // corresponding field on the form.
                // So errors are not displayed at the root of the form,
                // just like if the form was validated natively.
                $violationMapper = new ViolationMapper();

                foreach ($violations as $violation) {
                    $violationMapper->mapViolation($violation, $form);
                }
            }
        }
    }

    return $this->render('default/new.html.twig', array(
        'form' => $form->createView(),
    ));
}

One way to achieve what you want is by locking with the symfony LockHandler .

Here is a simple example, using the pattern you are referring in your question:

<?php

// ...
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Filesystem\LockHandler;
use Symfony\Component\Form\FormError;

public function newAction(Request $request)
{
    $task = new Task();

    $form = $this->createFormBuilder($task)
        ->add('task', TextType::class)
        ->add('dueDate', DateType::class)
        ->add('save', SubmitType::class, array('label' => 'Create Task'))
        ->getForm();

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // locking here
        $lock = new LockHandler('task_validator.lock');
        $lock->lock();

        // since entity is validated when the form is submitted, you
        // have to call the validator manually
        $validator = $this->get('validator');

        if (empty($validator->validate($task))) {
            $task = $form->getData();
            $em = $this->getDoctrine()->getManager();
            $em->persist($task);
            $em->flush();

            // lock is released by garbage collector
            return $this->redirectToRoute('task_success');
        }

        $form->addError(new FormError('An error occured, please retry'));
        // explicit release here to avoid keeping the Lock too much time.
        $lock->release();

    }

    return $this->render('default/new.html.twig', array(
        'form' => $form->createView(),
    ));
}

NB: This won't work if you run your application on multi hosts, from the documentation:

The lock handler only works if you're using just one server. If you have several hosts, you must not use this helper.

You could also override the EntityManager to create a new function like validateAndFlush($entity) that manage the LockHandler and the validation process itself.

Could you not set the unique constraint on database level. You can also check the Doctrine2 documentation on how to do this:

/**
 * @Entity
 * @Table(name="user",
 *      uniqueConstraints={@UniqueConstraint(name="username_unique", columns={"username"})},
 * )
 */
class User { 

    //...

    /**
     * @var string
     * @Column(type="string", name="username", nullable=false)
     */
    protected $username;

    //...
}

Now you have a unique restriction on database level (so the same username can never be inserted in the user table twice).

When you perform your insert operation you will get an exception thrown in case the username already exists (a UniqueConstraintViolationException ). You can catch the exception and return a valid response to the client where you communicate that this username was already used (was in your database).

If I understand the question correctly, you've set a very high bar for yourself. It's clearly impossible for your persistence layer to see the future. Thus, it's impossible to support a validator that will guarantee that the insert will succeed (and not throw a UniqueConstraintViolationException) using only your domain entities. You will need to maintain additional state somewhere.

If you want some incremental improvement, you'll need some way to reserve the username at validation time. That's easy enough, of course -- you just create a list somewhere to track "in-flight" usernames, and check that list in addition to checking your persistence layer during validation.

Where it gets tricky is designing a sane way to prune that list and release usernames that are submitted for validation, but never used in a successful registration.

That's an implementation detail, and you'll need to consider how long a username-reservation sticks around.

A simple implementation off the top of my head: Maintain a table in your database with (username, session_id, reserved_at) and have some process regularly delete all rows where reserved_at < :datetime.

You'll need to track the session_id, since you're reserving the username for a particular user. Since the user hasn't created an account yet, the only way to identify them is via their session identifier.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM