使用 honeystone/context 构建多租户应用程序

PHPz
发布: 2024-08-12 15:11:25
原创
1153 人浏览过

不要与 Laravel 的新上下文库混淆,该包可用于构建多上下文多租户应用程序。大多数多租户库本质上都有一个“租户”上下文,因此如果您需要多个上下文,事情可能会变得有点麻烦。这个新包解决了这个问题。

让我们看一个示例吧?

示例项目

对于我们的示例应用程序,我们将拥有一个全球用户群,即组织成团队,每个团队将有多个项目。这是许多软件即服务应用程序中相当常见的结构。

对于多租户应用程序来说,每个用户群都存在于租户上下文中并不罕见,但对于我们的示例应用程序,我们希望用户能够加入多个团队,所以是全球用户群。
全球用户群与租户用户群图

Building a multi-tenant application with honeystone/context

作为 SaaS,很可能该团队将是计费实体(即席位),并且某些团队成员将被授予管理团队的权限。不过,我不会在这个示例中深入探讨这些实现细节,但希望它提供一些额外的上下文。

安装

为了保持这篇文章的简洁,我不会解释如何启动你的 Laravel项目。已经有许多更好的资源可用,尤其是官方文档。让我们假设您已经有一个 Laravel 项目,包含用户、团队和项目模型,并且您已准备好开始实现我们的上下文包。

安装是一个简单的作曲家推荐:

composer install honeystone/context
登录后复制

这个库有一个方便的函数 context(),从 Laravel 11 开始,它与 Laravel 自己的 context 函数发生冲突。这并不是真正的问题。您可以导入我们的函数:

use function Honestone\Context\context;
登录后复制

或者只使用 Laravel 的依赖注入容器。在这篇文章中,我将假设您已导入该函数并相应地使用它。

模型

让我们从配置我们的团队模型开始:

<?php declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Team extends Model
{
    protected $fillable = [&#39;name&#39;];

    public function members(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }
}
登录后复制

团队有名称、成员和项目。在我们的应用程序中,只有团队成员才能访问该团队或其项目。

好的,让我们看看我们的项目:

<?php declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Project extends Model
{
    protected $fillable = [&#39;name&#39;];

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }
}
登录后复制

一个项目有一个名称并属于一个团队。

确定上下文

当有人访问我们的应用程序时,我们需要确定他们在哪个团队和项目中工作。为了简单起见,我们用路由参数来处理这个问题。我们还假设只有经过身份验证的用户才能访问该应用程序。

团队和项目上下文都不是: app.mysaas.dev
仅团队上下文: app.mysaas.dev/my-team
团队和项目上下文: app.mysaas.dev/my-team/my-project

我们的路线将如下所示:

Route::middleware('auth')->group(function () {

    Route::get('/', DashboardController::class);

    Route::middleware(AppContextMiddleware::Class)->group(function () {

        Route::get('/{team}', TeamController::class);
        Route::get('/{team}/{project}', ProjectController::class);
    });
});
登录后复制

考虑到命名空间冲突的可能性,这是一种非常不灵活的方法,但它使示例保持简洁。在现实世界的应用程序中,您需要稍微不同地处理这个问题,也许是 anothersaas.dev/teams/my-team/projects/my-project 或 my-team.anothersas.dev/projects/my-project。

我们应该首先看看我们的 AppContextMiddleware。该中间件初始化团队上下文,以及项目上下文(如果已设置):

<?php declare(strict_types=1);

namespace App\Http\Middleware;

use function Honestone\Context\context;

class TeamContextMiddleware
{
    public function handle(Request $request, Closure $next): mixed
    {
        //pull the team parameter from the route
        $teamId = $request->route('team');
        $request->route()->forgetParameter('team');

        $projectId = null;

        //if there's a project, pull that too
        if ($request->route()->hasParamater('project')) {

            $projectId = $request->route('project');
            $request->route()->forgetParameter('project');
        }

        //initialise the context
        context()->initialize(new AppResolver($teamId, $projectId));
    }
}
登录后复制

首先,我们从路由中获取团队 ID,然后忘记路由参数。一旦参数进入上下文,我们就不需要到达控制器。如果设置了项目 ID,我们也会提取它。然后,我们使用 AppResolver 传递团队 id 和项目 id(或 null)来初始化上下文:

<?php declare(strict_types=1);

namespace App\Context\Resolvers;

use App\Models\Team;
use Honeystone\Context\ContextResolver;
use Honeystone\Context\Contracts\DefinesContext;

use function Honestone\Context\context;

class AppResolver extends ContextResolver
{
    public function __construct(
        private readonly int $teamId,
        private readonly ?int $projectId = null,
    ) {}

    public function define(DefinesContext $definition): void 
    {
        $definition
            ->require('team', Team::class)
            ->accept('project', Project::class);
    }

    public function resolveTeam(): ?Team
    {
        return Team::with('members')->find($this->teamId);
    }

    public function resolveProject(): ?Project
    {
        return $this->projectId ?: Project::with('team')->find($this->projectId);
    }

    public function checkTeam(DefinesContext $definition, Team $team): bool
    {
        return $team->members->find(context()->auth()->getUser()) !== null;
    }

    public function checkProject(DefinesContext $definition, ?Project $project): bool
    {
        return $project === null || $project->team->id === $this->teamId;
    }

    public function deserialize(array $data): self
    {
        return new static($data['team'], $data['project']);
    }
}
登录后复制

这里还有更多内容。

define( ) 方法负责定义正在解析的上下文。团队是必需的,并且必须是团队模型,并且项目被接受(即可选)并且必须是项目模型(或为空)。

resolveTeam() 将在初始化时在内部调用。它返回 Team 或 null。如果出现空响应,ContextInitializer 将抛出 CouldNotResolveRequiredContextException。

resolveProject() 也将在初始化时在内部调用。它返回项目或 null。在这种情况下,空响应不会导致异常,因为定义不需要该项目。

解析团队和项目后,ContextInitializer 将调用可选的 checkTeam() 和 checkProject() 方法。这些方法执行完整性检查。对于 checkTeam(),我们确保经过身份验证的用户是团队的成员,对于 checkProject(),我们检查项目是否属于团队。

最后,每个解析器都需要一个 deserialization() 方法。此方法用于恢复序列化上下文。最值得注意的是,当在排队作业中使用上下文时,会发生这种情况。

Now that our application context is set, we should use it.

Accessing the context

As usual, we’ll keep it simple, if a little contrived. When viewing the team we want to see a list of projects. We could build our TeamController to handle this requirements like this:

<?php declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\View\View;

use function compact;
use function Honestone\Context\context;
use function view;

class TeamController
{
    public function __invoke(Request $request): View
    {
        $projects = context(&#39;team&#39;)->projects;

        return view('team', compact('projects'));
    }
}
登录后复制

Easy enough. The projects belonging to the current team context are passed to our view. Imagine we now need to query projects for a more specialised view. We could do this:

<?php declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\View\View;

use function compact;
use function Honestone\Context\context;
use function view;

class ProjectQueryController
{
    public function __invoke(Request $request, string $query): View
    {
        $projects = Project::where(&#39;team_id&#39;, context(&#39;team&#39;)->id)
            ->where('name', 'like', "%$query%")
            ->get();

        return view('queried-projects', compact('projects'));
    }
}
登录后复制

It’s getting a little fiddly now, and it’s far too easy to accidentally forget to ‘scope’ the query by team. We can solve this using the BelongsToContext trait on our Project model:

<?php declare(strict_types=1);

namespace App\Models;

use Honeystone\Context\Models\Concerns\BelongsToContext;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Project extends Model
{
    use BelongsToContext;

    protected static array $context = [&#39;team&#39;];

    protected $fillable = [&#39;name&#39;];

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }
}
登录后复制

All project queries will now be scooped by the team context and the current Team model will be automatically injected into new Project models.

Let’s simplify that controller:

<?php declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\View\View;

use function compact;
use function view;

class ProjectQueryController
{
    public function __invoke(Request $request, string $query): View
    {
        $projects = Project::where(&#39;name&#39;, &#39;like&#39;, "%$query%")->get();

        return view('queried-projects', compact('projects'));
    }
}
登录后复制

That’s all folks

From here onwards, you’re just building your application. The context is easily at hand, your queries are scoped and queued jobs will automagically have access to the same context from which they were dispatched.

Not all context related problems are solved though. You’ll probably want to create some validation macros to give your validation rules a little context, and don’t forget manual queries will not have the context automatically applied.

If you’re planning to use this package in your next project, we’d love to hear from you. Feedback and contribution is always welcome.

You can checkout the GitHub repository for additional documentation. If you find our package useful, please drop a star.

Until next time..


This article was originally posted to the Honeystone Blog. If you like our articles, consider checking our more of our content over there.

以上是使用 honeystone/context 构建多租户应用程序的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板
关于我们 免责声明 Sitemap
PHP中文网:公益在线PHP培训,帮助PHP学习者快速成长!