hakam/multi-tenancy-bundle

Symfony 扩展包,用于扩展 doctrine 支持数据库切换和多租户

安装: 10,765

依赖者: 0

建议者: 0

安全: 0

星级: 81

关注者: 8

分支: 24

开放性问题: 4

类型:symfony-bundle


README

Action Status Latest Stable Version Total Downloads License PHP Version Require Multi-Tenancy Bundle (Desktop Wallpaper)

Symfony 的多租户包是一个方便的解决方案,用于在您的 Symfony 应用程序中集成多租户数据库。它简化了使用 Doctrine 处理多个数据库的过程,允许您在运行时在它们之间切换。

此包包含一系列功能,包括通过触发事件轻松地在租户数据库之间切换的能力。此外,它支持主实体和租户实体的独立实体映射。它还包括用于独立管理租户数据库的自定义扩展 Doctrine 命令,以及为每个数据库分别生成和执行迁移的能力。

不久的将来,您还将能够通过单个命令执行所有租户数据库的批量迁移。此外,该包允许您创建和准备一个租户数据库,如果它尚未存在。

支持的数据库

  • MySQL / MariaDB
  • PostgreSQL
  • SQLite

安装

此包需要

使用 Composer 安装

$ composer require hakam/multi-tenancy-bundle

使用该包

此包背后的理念很简单,您有一个主数据库和多租户数据库,因此
  1. 创建特定的实体,这些实体应实现 TenantDbConfigurationInterface。在主数据库中保存所有租户数据库的配置。
  2. 您可以使用 TenantDbConfigTrait 来实现所需的完整数据库配置实体字段。
  3. 您可以使用 TimestampableTrait 向您的实体添加 createdAtupdatedAt 字段,然后您应该在实体类中添加 #[ORM\HasLifecycleCallbacks] 属性。
  4. 将实体分割到两个目录中,一个用于主数据库,一个用于租户数据库。例如 Main 和 Tenant
  5. 将迁移分割到两个目录中,一个用于主数据库,一个用于租户数据库。例如 Main 和 Tenant
  6. 更新您的 doctrine 配置以使用新的实体和迁移目录。请参见下面的示例。
# config/packages/doctrine.yaml
doctrine:
  dbal:
    default_connection: default
    url: '%env(resolve:DATABASE_URL)%'
    orm:
      default_entity_manager: default #set the default entity manager to use the main database 
      entity_managers:   
          default:
            connection: default #set the default entity manager  to use the default connection
            naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
            auto_mapping: true
            mappings:
              App:
                is_bundle: false
                dir: '%kernel.project_dir%/src/Entity/Main'
                prefix: 'App\Entity\Main'
                alias: App
                
# config/packages/doctrine_migrations.yaml
doctrine_migrations:
    migrations_paths:
        'DoctrineMigrations\Main': '%kernel.project_dir%/src/Migrations/Main'

您必须配置默认连接和默认实体管理器以使用主数据库。请参见上面的示例,以获取完整的配置。

您只需要更新主实体管理器的配置,然后该包将为您处理其余部分。

  1. TenantEntityManager 添加到您的服务或控制器参数中。

  2. 使用自定义租户数据库标识符的值分发 SwitchDbEvent。例如 new SwitchDbEvent(1)

  3. 您可以通过分发具有不同数据库标识符的相同事件来在所有租户数据库之间切换。

  4. 现在,您的 TenantEntityManager 实例已连接到标识符为 1 的租户数据库。

  5. 建议将租户实体放在与主实体不同的目录中。

  6. 您可以使用我们的代理命令为租户数据库执行 doctrine 迁移命令。

    php bin/console tenant:database:create   # t:d:c  for short , To create non existing tenant dbs list 
    
    php bin/console tenant:migration:diff    # t:m:d  for short , To generate migraiton for tenant db 
    
    php bin/console tenant:migration:migrate init  # t:m:m init , To run migraitons for  new tenant dbs up to the latest version.
    
    php bin/console tenant:migration:migrate update  # t:m:m update , To run migrations for  all tenant db to the latest version.
    

示例

您可以检查这个项目示例 多租户包示例 来了解如何使用这个包。

注意事项

所有 doctrine 迁移命令和文件都是专门为租户数据库生成的和执行的,独立于主数据库迁移,感谢 Doctrine 迁移包 v3+。

所有租户数据库迁移都将保存在您为租户实体配置的相同目录中。

  Update:  Now you can have different host, username and password for tenant dbs.
  Update:  All tenant databases share the same `dbusername` and `dbpassword` from the selected tenant host.
  Update:  Now you can have different driver for tenant dbs and main db.

入门示例

  1. 按照我们上面提到的方式配置了包之后,您可以按照以下步骤开始。
  2. 首先需要使用新实体生成主数据库迁移。例如 php bin/console doctrine:migrations:diff
  3. 然后迁移主数据库。例如 php bin/console doctrine:migrations:migrate
  4. 现在您可以创建新的租户数据库并在它们之间切换了。
  5. 要创建新的租户数据库并为其执行迁移。将 TenantDbConfig 实体添加到主数据库中。然后您可以使用以下命令。例如 php bin/console tenant:database:create 这个命令将创建一个新的租户数据库并为其执行迁移。
  6. 要切换到租户数据库。例如 $this->dispatcher->dispatch(new SwitchDbEvent($tenantDb->getId())); 然后您可以使用 TenantEntityManager 来执行您的查询。
  7. 您可以在您的租户实体和 TenantDbConfig 实体之间添加一个关系,以轻松获取租户数据库配置。
<?php
      public Class AppController extends AbstractController
      {
        public function __construct(
        private EntityManagerInterface $mainEntityManager,
        private TenantEntityManager $tenantEntityManager,
        private EventDispatcherInterface $dispatcher
    ) {
    }
      public function switchToLoggedInUserTenantDb(): void
      {
        $this->dispatcher->dispatch(new SwitchDbEvent($this->getUser()->getTenantDbConfig()->getId()));
        // Now you can use the tenant entity manager to execute your queries.
      }
    }
  1. 您可以使用针对租户数据库的定制 doctrine 命令,在租户数据库上执行相同的 doctrine 命令。例如 php bin/console tenant:migration:diff 生成租户数据库的迁移。例如 php bin/console tenant:migration:migrate update 将所有租户数据库迁移到最新版本。
  2. 现在您可以像使用主实体管理器一样使用 TenantEntityManager
  3. 现在您可以分别为主或租户数据库添加或删除实体,并为每个数据库单独生成迁移。
<?php

namespace App\Controller;

use App\Entity\Main\TenantDbConfig;
use Hakam\MultiTenancyBundle\Services\DbService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Hakam\MultiTenancyBundle\Event\SwitchDbEvent;
use Hakam\MultiTenancyBundle\Doctrine\ORM\TenantEntityManager;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Tenant\TestEntity;
use App\Entity\Main\MainEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class MultiTenantController extends AbstractController
{
    public function __construct(
        private EntityManagerInterface $mainEntityManager,
        private TenantEntityManager $tenantEntityManager,
        private EventDispatcherInterface $dispatcher,
        private DbService $dbService,
    ) {
    }
    /**
     * An example of how to switch and update tenant databases
     */
    #[Route('/test_db', name: 'app_test_db')]
    public function testDb(EntityManagerInterface $entityManager)
    {

        $tenantDbConfigs = $this->mainEntityManager->getRepository(TenantDbConfig::class)->findAll();

        foreach ($tenantDbConfigs as $tenantDbConfig) {
            // Dispatch an event with the index ID for the entity that contains the tenant database connection details.
            $switchEvent = new SwitchDbEvent($tenantDbConfig->getId());
            $this->dispatcher->dispatch($switchEvent);

            $tenantEntity1 = new TestEntity();
            $tenantEntity1->setName($tenantDbConfig->getDbName());

            $this->tenantEntityManager->persist($tenantEntity1);
            $this->tenantEntityManager->flush();
        }

        // Add a new entity to the main database.
        $mainLog = new MainEntity();
        $mainLog->setName('mainTest');
        $this->mainEntityManager->persist($mainLog);
        $this->mainEntityManager->flush();

        return new JsonResponse();
    }
}

配置

在下面的示例中,您可以找到所有必需的配置参数列表,您应该在 config/packages/hakam_multi_tenancy.yaml 中创建这些参数,使用此配置

hakam_multi_tenancy:
 tenant_database_className:  App\Entity\Main\TenantDbConfig    # tenant dbs configuration Class Name
 tenant_database_identifier: id                                # tenant db column name to get db configuration
 tenant_connection:                                            # tenant entity manager connection configuration
   host:     127.0.0.1
   port:     3306                                              # default is 3306
   driver:   pdo_mysql 
   charset:  utf8 
   server_version: 5.7                                         # mysql server version

 tenant_migration:                                             # tenant db migration configurations, Its recommended to have a different migration for tenants dbs than you main migration config
   tenant_migration_namespace: Application\Migrations\Tenant
   tenant_migration_path: migrations/Tenant
 tenant_entity_manager:                                        # tenant entity manger configuration , which is used to manage tenant entities
   tenant_naming_strategy:                                       # tenant entity manager naming strategy
       dql:                                             # tenant entity manager dql configuration
         string_functions:
           test_string: App\DQL\StringFunction
           second_string: App\DQL\SecondStringFunction
         numeric_functions:
           test_numeric: App\DQL\NumericFunction
         datetime_functions:
           test_datetime: App\DQL\DatetimeFunction
   mapping:                                                  
     type:   attribute                                          # mapping type default annotation                                                       
     dir:   '%kernel.project_dir%/src/Entity/Tenant'           # directory of tenant entities, it could be different from main directory                                           
     prefix: App\Entity\Tenant                                 # tenant entities prefix  ex "App\Entity\Tenant"
     alias:   Tenant                                           # tenant entities alias  ex "Tenant"

使用建议模式

用户

将用户当前租户 ID 存储在会话或用户实体中。这允许您随时获取当前租户。

租户接口

在所有租户实体上实现一个接口。这允许您确定一个实体是否与租户对象相关联。然后您可以从当前用户中获取当前租户 ID,并将实体管理器切换到租户数据库。

namespace App\Entity\Tenant;

use App\Model\OrgActivitySuperclass;
use App\Repository\Tenant\OrgActivityRepository;
use Doctrine\ORM\Mapping as ORM;
use Hakam\MultiTenancyBundle\Model\TenantEntityInterface;

#[ORM\Entity(repositoryClass: OrgActivityRepository::class)]
class OrgActivity extends OrgActivitySuperclass implements TenantEntityInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
           
}

自定义控制器

扩展您的基控制器类以覆盖一些用于持久化的正常控制器函数。

<?php

namespace App\Controller;

use App\Entity\Main\Tenant;
use App\Entity\Tenant\Organisation;
use Hakam\MultiTenancyBundle\Doctrine\ORM\TenantEntityManager;
use Hakam\MultiTenancyBundle\Event\SwitchDbEvent;
use Hakam\MultiTenancyBundle\Model\TenantEntityInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Umbrella\CoreBundle\Controller\BaseController;

abstract class DbSwitcherController extends BaseController
{
    public function __construct(private EventDispatcherInterface $eventDispatcher, private TenantEntityManager $tenantEntityManager)
    {
    }
    
    protected function switchDb(Tenant $tenant): Organisation
    {

        // Switch the TenantEntityManager to the current tenant.
        $event = new SwitchDbEvent($tenant->getId());
        $this->eventDispatcher->dispatch($event);

        // Optional depending on your usage, here we return the top entity whenever we switch to a new Tenant DB.
        $organisation = $this->tenantEntityManager->getRepository(Organisation::class)
            ->findOneIdByXeroOrganisationId($tenant->getXeroOrganisationId());

        return $organisation;
    }

    /**
     * Override parent method to check if the entity is a Tenant entity or main entity. Return which ever is appropriate.
     */  
    protected function findOrNotFound(string $className, $id)
    {

        $em = $this->em();

        $reflection = new \ReflectionClass($className);

        if ($reflection instanceof TenantEntityInterface) {
            $em = $this->tenantEntityManager;
        }

        $e = $em->find($className, $id);
        $this->throwNotFoundExceptionIfNull($e);

        return $e;
    }

    /**
     * Override parent method to check if the entity is a Tenant entity or main entity. Return which ever is appropriate.
     */  
    protected function persistAndFlush($entity): void
    {
        if ($entity instanceof TenantEntityInterface) {
            $this->tenantEntityManager->persist($entity);
            $this->tenantEntityManager->flush();
            return;
        }
        $this->em()->persist($entity);
        $this->em()->flush();
    }

    /**
     * Override parent method to check if the entity is a Tenant entity or main entity. Return which ever is appropriate.
     */  
    protected function removeAndFlush($entity): void
    {
        if ($entity instanceof TenantEntityInterface) {
            $this->tenantEntityManager->remove($entity);
            $this->tenantEntityManager->flush();
            return;
        }
        $this->em()->remove($entity);
        $this->em()->flush();
    }
}

自定义值解析器

Symfony 使用实体值解析器来加载与控制器操作中传递的参数相关联的实体。https://symfony.com.cn/doc/current/controller/value_resolver.html

此解析器对于确保您的 IsGranted 和其他安全操作在控制器中工作至关重要。

创建一个自定义实体值解析器,根据实体的 ReflectionClass(基于 TenantEntityInterface)切换。

您可以从这里复制值解析器: https://github.com/symfony/symfony/blob/6.2/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php

并修改它以排除非 TenantEntityInterface 对象。然后根据当前用户租户切换数据库。

<?php

namespace App\ValueResolver;

namespace App\ValueResolver;

use Doctrine\DBAL\Types\ConversionException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Hakam\MultiTenancyBundle\Doctrine\ORM\TenantEntityManager;
use Hakam\MultiTenancyBundle\Event\SwitchDbEvent;
use Hakam\MultiTenancyBundle\Model\TenantEntityInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class TenantEntityValueResolver implements ValueResolverInterface
{
    public function __construct(
        private ManagerRegistry $registry,
        private Security $security,
        private EventDispatcherInterface $eventDispatcher,
        private TenantEntityManager $tenantEntityManager,
        private MapEntity $defaults = new MapEntity(),
        private ?ExpressionLanguage $expressionLanguage = null,
    ) {
    }

    public function resolve(Request $request, ArgumentMetadata $argument): array
    {
        if (\is_object($request->attributes->get($argument->getName()))) {
            return [];
        }

        $options = $argument->getAttributes(MapEntity::class, ArgumentMetadata::IS_INSTANCEOF);
        $options = ($options[0] ?? $this->defaults)->withDefaults($this->defaults, $argument->getType());

        if (!$options->class || $options->disabled) {
            return [];
        }

        $reflectionClass = new \ReflectionClass($options->class);

        if(!$reflectionClass instanceof TenantEntityInterface){
            return [];
        }

        $currentTenant = $this->security->getUser()->getCurrentTenant();

        $switchEvent = new SwitchDbEvent($currentTenant->getId());
        $this->eventDispatcher->dispatch($switchEvent);

        $manager = $this->tenantEntityManager;

        if (!$manager instanceof TenantEntityManager) {
            return [];
        }

        $message = '';
        if (null !== $options->expr) {
            if (null === $object = $this->findViaExpression($manager, $request, $options)) {
                $message = sprintf(' The expression "%s" returned null.', $options->expr);
            }
            // find by identifier?
        } elseif (false === $object = $this->find($manager, $request, $options, $argument->getName())) {
            // find by criteria
            if (!$criteria = $this->getCriteria($request, $options, $manager)) {
                return [];
            }
            try {
                $object = $manager->getRepository($options->class)->findOneBy($criteria);
            } catch (NoResultException|ConversionException) {
                $object = null;
            }
        }

        if (null === $object && !$argument->isNullable()) {
            throw new NotFoundHttpException(sprintf('"%s" object not found by "%s".', $options->class, self::class).$message);
        }

        return [$object];
    }

    private function getManager(?string $name, string $class): ?ObjectManager
    {
        if (null === $name) {
            return $this->registry->getManagerForClass($class);
        }

        try {
            $manager = $this->registry->getManager($name);
        } catch (\InvalidArgumentException) {
            return null;
        }

        return $manager->getMetadataFactory()->isTransient($class) ? null : $manager;
    }

    private function find(ObjectManager $manager, Request $request, MapEntity $options, string $name): false|object|null
    {
        if ($options->mapping || $options->exclude) {
            return false;
        }

        $id = $this->getIdentifier($request, $options, $name);
        if (false === $id || null === $id) {
            return $id;
        }

        if ($options->evictCache && $manager instanceof EntityManagerInterface) {
            $cacheProvider = $manager->getCache();
            if ($cacheProvider && $cacheProvider->containsEntity($options->class, $id)) {
                $cacheProvider->evictEntity($options->class, $id);
            }
        }

        try {
            return $manager->getRepository($options->class)->find($id);
        } catch (NoResultException|ConversionException) {
            return null;
        }
    }

    private function getIdentifier(Request $request, MapEntity $options, string $name): mixed
    {
        if (\is_array($options->id)) {
            $id = [];
            foreach ($options->id as $field) {
                // Convert "%s_uuid" to "foobar_uuid"
                if (str_contains($field, '%s')) {
                    $field = sprintf($field, $name);
                }

                $id[$field] = $request->attributes->get($field);
            }

            return $id;
        }

        if (null !== $options->id) {
            $name = $options->id;
        }

        if ($request->attributes->has($name)) {
            return $request->attributes->get($name) ?? ($options->stripNull ? false : null);
        }

        if (!$options->id && $request->attributes->has('id')) {
            return $request->attributes->get('id') ?? ($options->stripNull ? false : null);
        }

        return false;
    }

    private function getCriteria(Request $request, MapEntity $options, ObjectManager $manager): array
    {
        if (null === $mapping = $options->mapping) {
            $mapping = $request->attributes->keys();
        }

        if ($mapping && \is_array($mapping) && array_is_list($mapping)) {
            $mapping = array_combine($mapping, $mapping);
        }

        foreach ($options->exclude as $exclude) {
            unset($mapping[$exclude]);
        }

        if (!$mapping) {
            return [];
        }

        // if a specific id has been defined in the options and there is no corresponding attribute
        // return false in order to avoid a fallback to the id which might be of another object
        if (\is_string($options->id) && null === $request->attributes->get($options->id)) {
            return [];
        }

        $criteria = [];
        $metadata = $manager->getClassMetadata($options->class);

        foreach ($mapping as $attribute => $field) {
            if (!$metadata->hasField($field) && (!$metadata->hasAssociation($field) || !$metadata->isSingleValuedAssociation($field))) {
                continue;
            }

            $criteria[$field] = $request->attributes->get($attribute);
        }

        if ($options->stripNull) {
            $criteria = array_filter($criteria, static fn ($value) => null !== $value);
        }

        return $criteria;
    }

    private function findViaExpression(ObjectManager $manager, Request $request, MapEntity $options): ?object
    {
        if (!$this->expressionLanguage) {
            throw new \LogicException(sprintf('You cannot use the "%s" if the ExpressionLanguage component is not available. Try running "composer require symfony/expression-language".', __CLASS__));
        }

        $repository = $manager->getRepository($options->class);
        $variables = array_merge($request->attributes->all(), ['repository' => $repository]);

        try {
            return $this->expressionLanguage->evaluate($options->expr, $variables);
        } catch (NoResultException|ConversionException) {
            return null;
        }
    }
}

将值解析器添加为服务。

services:
    # Priority should fire before the default EntityValueResolver
    App\ValueResolver\TenantEntityValueResolver:
        tags:
            - { name: controller.argument_value_resolver, priority: 150 }

贡献

想要贡献?太好了!

  • 从存储库中分叉您的副本
  • 添加您的新酷炫功能
  • 编写更多测试
  • 创建新的拉取请求

许可证

MIT

自由软件,太棒了!