05. Change CountFollowersQuery to make use a Read Model based on Redis to improve performance 🚀 👉 Projection is computed directly on the event handler.

This commit is contained in:
theUniC
2022-09-04 17:19:15 +02:00
parent 2644cc59d6
commit 7ed3deb2b5
29 changed files with 374 additions and 78 deletions

6
.env
View File

@@ -27,6 +27,7 @@ APP_SECRET=24d45e8c51d769273b29ee9236ba01e4
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
DATABASE_URL=mysql://user:pass@127.0.0.1:3306/db?serverVersion=mariadb-10.9.2
###< doctrine/doctrine-bundle ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
@@ -37,3 +38,8 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
###< symfony/messenger ###
###> snc/redis-bundle ###
# passwords that contain special characters (@, %, :, +) must be urlencoded
REDIS_URL=redis://localhost
###< snc/redis-bundle ###

View File

@@ -1,3 +1,4 @@
APP_ENV=dev
APP_SECRET=24d45e8c51d769273b29ee9236ba01e4
DATABASE_URL=mysql://user:pass@mysql:3306/db?serverVersion=mariadb-10.9.2
REDIS_URL=redis://redis

View File

@@ -0,0 +1,7 @@
<?php
class Redis
{
/** @return false|string */
public function get(string $key);
}

View File

@@ -8,6 +8,7 @@ RUN apt-get update \
libicu-dev \
libzip-dev \
unzip \
redis \
$PHPIZE_DEPS \
&& docker-php-ext-install pdo_mysql mysqli pcntl bcmath intl zip \
&& pecl install redis \

View File

@@ -21,6 +21,7 @@
"phpstan/phpdoc-parser": "^1.6",
"ramsey/uuid": "^4.0",
"ramsey/uuid-doctrine": "^1.6",
"snc/redis-bundle": "^4.3",
"symfony/apache-pack": "^1.0",
"symfony/asset": "6.1.*",
"symfony/console": "6.1.*",
@@ -54,6 +55,8 @@
"keyvanakbary/mimic": "^1.0",
"mockery/mockery": "^1.5",
"php-standard-library/psalm-plugin": "^2.0",
"phpspec/prophecy": "^1.15",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.0",
"psalm/plugin-mockery": "^0.9.1",
"psalm/plugin-phpunit": "^0.17.0",
@@ -69,7 +72,7 @@
"symfony/phpunit-bridge": "^6.1",
"symfony/stopwatch": "6.1.*",
"symfony/web-profiler-bundle": "6.1.*",
"theofidry/psysh-bundle": "^4.3",
"theofidry/psysh-bundle": "^4.5",
"vimeo/psalm": "^4.26",
"weirdan/doctrine-psalm-plugin": "^2.3"
},

211
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "af06f3081839152655c23b4db1073ec2",
"content-hash": "c63441fd0cbff30a03c0eb01d4de0708",
"packages": [
{
"name": "azjezz/psl",
@@ -2610,6 +2610,96 @@
},
"time": "2022-08-01T03:01:07+00:00"
},
{
"name": "snc/redis-bundle",
"version": "4.3.0",
"source": {
"type": "git",
"url": "https://github.com/snc/SncRedisBundle.git",
"reference": "5ac15ab0824a4a4448ba27edd2e2773c159c373d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/snc/SncRedisBundle/zipball/5ac15ab0824a4a4448ba27edd2e2773c159c373d",
"reference": "5ac15ab0824a4a4448ba27edd2e2773c159c373d",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"symfony/framework-bundle": "^4.4 || ^5.3 || ^6.0",
"symfony/http-foundation": "^4.4 || ^5.3 || ^6.0",
"symfony/var-dumper": "^4.4 || ^5.3 || ^6.0"
},
"conflict": {
"ext-redis": "<5.3",
"predis/predis": "<1.1,>=2.0"
},
"require-dev": {
"doctrine/annotations": "^1.13",
"doctrine/coding-standard": "^9.0",
"ext-pdo_sqlite": "*",
"ext-redis": "*",
"friendsofphp/proxy-manager-lts": "^1.0.6",
"monolog/monolog": "*",
"phpunit/phpunit": "^8.5 || ^9.5",
"predis/predis": ">=1.1",
"symfony/browser-kit": "^4.4 || ^5.3 || ^6.0",
"symfony/cache": "^4.4 || ^5.3 || ^6.0",
"symfony/console": "^4.4 || ^5.3 || ^6.0",
"symfony/dom-crawler": "^4.4 || ^5.3 || ^6.0",
"symfony/filesystem": "^4.4 || ^5.3 || ^6.0",
"symfony/phpunit-bridge": "^6.0",
"symfony/profiler-pack": "^1.0",
"symfony/proxy-manager-bridge": "^4.4 || ^5.3 || ^6.0",
"symfony/stopwatch": "^4.4 || ^5.3 || ^6.0",
"symfony/twig-bundle": "^4.4 || ^5.3 || ^6.0",
"symfony/yaml": "^4.4 || ^5.3 || ^6.0",
"vimeo/psalm": "^4.13"
},
"suggest": {
"monolog/monolog": "If you want to use the monolog redis handler.",
"predis/predis": "If you want to use predis.",
"symfony/console": "If you want to use commands to interact with the redis database",
"symfony/proxy-manager-bridge": "If you want to lazy-load some services"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Snc\\RedisBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Henrik Westphal",
"email": "henrik.westphal@gmail.com"
},
{
"name": "Community contributors",
"homepage": "https://github.com/snc/SncRedisBundle/contributors"
}
],
"description": "A Redis bundle for Symfony",
"homepage": "https://github.com/snc/SncRedisBundle",
"keywords": [
"nosql",
"redis",
"symfony"
],
"support": {
"issues": "https://github.com/snc/SncRedisBundle/issues",
"source": "https://github.com/snc/SncRedisBundle/tree/4.3.0"
},
"time": "2022-07-13T20:58:05+00:00"
},
{
"name": "symfony/apache-pack",
"version": "v1.0.1",
@@ -9838,6 +9928,125 @@
},
"time": "2022-02-27T20:08:05+00:00"
},
{
"name": "phpspec/prophecy",
"version": "v1.15.0",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
"reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
"reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
"shasum": ""
},
"require": {
"doctrine/instantiator": "^1.2",
"php": "^7.2 || ~8.0, <8.2",
"phpdocumentor/reflection-docblock": "^5.2",
"sebastian/comparator": "^3.0 || ^4.0",
"sebastian/recursion-context": "^3.0 || ^4.0"
},
"require-dev": {
"phpspec/phpspec": "^6.0 || ^7.0",
"phpunit/phpunit": "^8.0 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Prophecy\\": "src/Prophecy"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Konstantin Kudryashov",
"email": "ever.zet@gmail.com",
"homepage": "http://everzet.com"
},
{
"name": "Marcello Duarte",
"email": "marcello.duarte@gmail.com"
}
],
"description": "Highly opinionated mocking framework for PHP 5.3+",
"homepage": "https://github.com/phpspec/prophecy",
"keywords": [
"Double",
"Dummy",
"fake",
"mock",
"spy",
"stub"
],
"support": {
"issues": "https://github.com/phpspec/prophecy/issues",
"source": "https://github.com/phpspec/prophecy/tree/v1.15.0"
},
"time": "2021-12-08T12:19:24+00:00"
},
{
"name": "phpspec/prophecy-phpunit",
"version": "v2.0.1",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy-phpunit.git",
"reference": "2d7a9df55f257d2cba9b1d0c0963a54960657177"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy-phpunit/zipball/2d7a9df55f257d2cba9b1d0c0963a54960657177",
"reference": "2d7a9df55f257d2cba9b1d0c0963a54960657177",
"shasum": ""
},
"require": {
"php": "^7.3 || ^8",
"phpspec/prophecy": "^1.3",
"phpunit/phpunit": "^9.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Prophecy\\PhpUnit\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christophe Coevoet",
"email": "stof@notk.org"
}
],
"description": "Integrating the Prophecy mocking library in PHPUnit test cases",
"homepage": "http://phpspec.net",
"keywords": [
"phpunit",
"prophecy"
],
"support": {
"issues": "https://github.com/phpspec/prophecy-phpunit/issues",
"source": "https://github.com/phpspec/prophecy-phpunit/tree/v2.0.1"
},
"time": "2020-07-09T08:33:42+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "9.2.17",

View File

@@ -17,4 +17,5 @@ return [
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Snc\RedisBundle\SncRedisBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,16 @@
snc_redis:
clients:
default:
type: phpredis
alias: default
dsn: "%env(REDIS_URL)%"
# Define your clients here. The example below connects to database 0 of the default Redis server.
#
# See https://github.com/snc/SncRedisBundle/blob/master/docs/README.md for instructions on
# how to configure the bundle.
#
# default:
# type: phpredis
# alias: default
# dsn: "%env(REDIS_URL)%"

View File

@@ -13,6 +13,7 @@ services:
Cheeper\DomainModel\Cheep\CheepRepository: '@Cheeper\Infrastructure\Persistence\DoctrineOrmCheepRepository'
Cheeper\DomainModel\Follow\FollowRepository: '@Cheeper\Infrastructure\Persistence\DoctrineOrmFollowRepository'
Cheeper\Application\EventBus: '@Cheeper\Infrastructure\Application\SymfonyMessengerEventBus'
Redis: '@snc_redis.default'
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name

View File

@@ -13,13 +13,13 @@ services:
build:
dockerfile: Dockerfile
context: .
target: dev
args:
- UID=${UID:-}
- GID=${GID:-}
target: local
init: true
env_file: .env.docker.dist
ports:
- "8000:80"
volumes:
- ./:/var/www/html
- ./:/var/www/html
redis:
image: redis
ports:
- "6379:6379"

View File

@@ -15,6 +15,10 @@
</ignoreFiles>
</projectFiles>
<stubs>
<file name=".psalm-stubs/phpredis.phpstub"/>
</stubs>
<plugins>
<pluginClass class="Psalm\SymfonyPsalmPlugin\Plugin"/>
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Cheeper\Application\AuthorWasFollowed;
use Cheeper\DomainModel\Author\AuthorWasFollowed;
use Redis;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class AuthorWasFollowedEventHandler
{
public function __construct(
private readonly Redis $redis,
) {
}
public function __invoke(AuthorWasFollowed $event): void
{
$this->redis->incr("followers_of:" . $event->toAuthorId);
}
}

View File

@@ -7,4 +7,4 @@ namespace Cheeper\Application;
/** @psalm-immutable */
interface Command
{
}
}

View File

@@ -14,4 +14,4 @@ final class CountFollowersQuery implements Query
public readonly string $authorId
) {
}
}
}

View File

@@ -4,43 +4,17 @@ declare(strict_types=1);
namespace Cheeper\Application\CountFollowers;
use Cheeper\DomainModel\Author\Author;
use Cheeper\DomainModel\Author\AuthorDoesNotExist;
use Cheeper\DomainModel\Author\AuthorId;
use Cheeper\DomainModel\Author\AuthorRepository;
use Cheeper\DomainModel\Follow\FollowRepository;
final class CountFollowersQueryHandler
{
public function __construct(
private readonly AuthorRepository $authorRepository,
private readonly FollowRepository $followRepository,
private readonly \Redis $redis,
) {
}
public function __invoke(CountFollowersQuery $query): int
{
$this->tryToFindAuthor($query->authorId);
$totalFollowers = $this->redis->get("followers_of:" . $query->authorId);
return $this->followRepository->numberOfFollowersFor(
AuthorId::fromString($query->authorId)
);
return false === $totalFollowers ? 0 : (int)$totalFollowers;
}
/** @psalm-param non-empty-string $authorId */
private function tryToFindAuthor(string $authorId): void
{
$id = AuthorId::fromString($authorId);
$author = $this->authorRepository->ofId($id);
$this->assertAuthorIsNotNull($author, $id);
}
/** @psalm-assert Author $author */
private function assertAuthorIsNotNull(Author|null $author, AuthorId $id): void
{
if (null === $author) {
throw AuthorDoesNotExist::withAuthorIdOf($id);
}
}
}
}

View File

@@ -13,4 +13,4 @@ interface EventBus
* @param DomainEvent[] $events
*/
public function publishAll(array $events): void;
}
}

View File

@@ -18,4 +18,4 @@ final class FollowCommand implements Command
public readonly string $toAuthorId,
) {
}
}
}

View File

@@ -17,8 +17,7 @@ final class FollowCommandHandler
private readonly AuthorRepository $authorRepository,
private readonly FollowRepository $followRepository,
private readonly EventBus $eventBus,
)
{
) {
}
public function __invoke(FollowCommand $command): void
@@ -54,4 +53,4 @@ final class FollowCommandHandler
throw AuthorDoesNotExist::withAuthorIdOf($id);
}
}
}
}

View File

@@ -7,4 +7,4 @@ namespace Cheeper\Application;
/** @psalm-immutable */
interface Query
{
}
}

View File

@@ -4,21 +4,23 @@ declare(strict_types=1);
namespace Cheeper\DomainModel;
trait RecordsEvents
/** @template T of DomainEvent */
abstract class AggregateRoot
{
/**
* @psalm-var list<DomainEvent>
* @psalm-var list<T>
* @var DomainEvent[]
*/
private array $events = [];
private function recordThat(DomainEvent $eventHappened): void
/** @psalm-param T $eventHappened */
protected function recordThat(DomainEvent $eventHappened): void
{
$this->events[] = $eventHappened;
}
/**
* @psalm-return list<DomainEvent>
* @psalm-return list<T>
* @return DomainEvent[]
*/
public function pullEvents(): array
@@ -28,4 +30,4 @@ trait RecordsEvents
return $events;
}
}
}

View File

@@ -15,4 +15,4 @@ final class AuthorWasFollowed implements DomainEvent
public readonly \DateTimeImmutable $occurredOn,
) {
}
}
}

View File

@@ -7,4 +7,4 @@ namespace Cheeper\DomainModel;
/** @psalm-immutable */
interface DomainEvent
{
}
}

View File

@@ -4,16 +4,17 @@ declare(strict_types=1);
namespace Cheeper\DomainModel\Follow;
use Cheeper\DomainModel\AggregateRoot;
use Cheeper\DomainModel\Author\AuthorId;
use Cheeper\DomainModel\Author\AuthorWasFollowed;
use Cheeper\DomainModel\Clock\Clock;
use Cheeper\DomainModel\RecordsEvents;
/** @final */
class Follow
/**
* @final
* @extends AggregateRoot<AuthorWasFollowed>
*/
class Follow extends AggregateRoot
{
use RecordsEvents;
/**
* @psalm-param non-empty-string $followId
* @psalm-param non-empty-string $fromAuthorId

View File

@@ -16,6 +16,7 @@ final class InMemoryEventBus implements EventBus
*/
private array $events = [];
/** @psalm-param list<DomainEvent> $events */
public function publishAll(array $events): void
{
$this->events = Vec\concat($this->events, $events);
@@ -29,4 +30,4 @@ final class InMemoryEventBus implements EventBus
{
return $this->events;
}
}
}

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Cheeper\Infrastructure\Application;
use Cheeper\Application\EventBus;
use Symfony\Component\Messenger\MessageBusInterface;
use Psl\Iter;
use Symfony\Component\Messenger\MessageBusInterface;
final class SymfonyMessengerEventBus implements EventBus
{
@@ -19,4 +19,4 @@ final class SymfonyMessengerEventBus implements EventBus
{
Iter\apply($events, $this->messageBus->dispatch(...));
}
}
}

View File

@@ -335,6 +335,18 @@
"sebastian/version": {
"version": "3.0.0"
},
"snc/redis-bundle": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "2.0",
"ref": "36b3d9ab65be62de4e085a25e6ca899efa96b1f3"
},
"files": [
"config/packages/snc_redis.yaml"
]
},
"symfony/apache-pack": {
"version": "1.0",
"recipe": {

View File

@@ -24,11 +24,10 @@ final class GetFollowersCountControllerTest extends ApiTestCase
// Make first follow second author
$this->makeFollow($client, $fromAuthor['id'], $toAuthor['id']);
sleep(2);
$totalFollowers = $this->getFollowersCount($client, $toAuthor['id']);
$this->assertSame(1, $totalFollowers);
}
}

View File

@@ -20,6 +20,8 @@ final class PostFollowersControllerTest extends ApiTestCase
$this->makeFollow($client, $firstAuthor['id'], $secondAuthor['id']);
sleep(3);
$this->assertSame(0, $this->getFollowersCount($client, $firstAuthor['id']));
$this->assertSame(1, $this->getFollowersCount($client, $secondAuthor['id']));
}

View File

@@ -4,43 +4,47 @@ declare(strict_types=1);
namespace Cheeper\Tests\Application;
use Cheeper\Application\AuthorWasFollowed\AuthorWasFollowedEventHandler;
use Cheeper\Application\CountFollowers\CountFollowersQuery;
use Cheeper\Application\CountFollowers\CountFollowersQueryHandler;
use Cheeper\Application\Follow\FollowCommand;
use Cheeper\Application\Follow\FollowCommandHandler;
use Cheeper\DomainModel\Author\AuthorDoesNotExist;
use Cheeper\DomainModel\Author\AuthorRepository;
use Cheeper\DomainModel\Author\AuthorWasFollowed;
use Cheeper\DomainModel\Follow\FollowRepository;
use Cheeper\Infrastructure\Application\InMemoryEventBus;
use Cheeper\Infrastructure\Persistence\InMemoryAuthorRepository;
use Cheeper\Infrastructure\Persistence\InMemoryFollowRepository;
use Cheeper\Tests\DomainModel\Author\AuthorTestDataBuilder;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psl\Iter;
use Psl\Type;
use Redis;
final class CountFollowersQueryHandlerTest extends TestCase
{
use ProphecyTrait;
/** @var ObjectProphecy<Redis> */
private ObjectProphecy $redis;
private AuthorRepository $authorRepository;
private FollowRepository $followRepository;
private CountFollowersQueryHandler $countFollowersQueryHandler;
private AuthorWasFollowedEventHandler $authorWasFollowedEventHandler;
private int $followers = 0;
protected function setUp(): void
{
$this->redis = $this->prophesize(\Redis::class);
$this->authorRepository = new InMemoryAuthorRepository();
$this->followRepository = new InMemoryFollowRepository();
$this->countFollowersQueryHandler = new CountFollowersQueryHandler($this->authorRepository, $this->followRepository);
}
/** @test */
public function givenFollowersCountForANonExistingAuthorWhenCountIsRequestedThenAnExceptionShouldBeThrown(): void
{
$this->expectException(AuthorDoesNotExist::class);
$authorId = AuthorTestDataBuilder::anAuthorIdentity()->id;
($this->countFollowersQueryHandler)(
new CountFollowersQuery($authorId)
);
$redis = $this->redis->reveal();
$this->countFollowersQueryHandler = new CountFollowersQueryHandler($redis);
$this->authorWasFollowedEventHandler = new AuthorWasFollowedEventHandler($redis);
}
/** @test */
@@ -59,11 +63,41 @@ final class CountFollowersQueryHandlerTest extends TestCase
$authorId = $author->authorId()->id;
$followCommandHandler = new FollowCommandHandler($this->authorRepository, $this->followRepository, new InMemoryEventBus());
$eventBus = new InMemoryEventBus();
$followCommandHandler = new FollowCommandHandler($this->authorRepository, $this->followRepository, $eventBus);
($followCommandHandler)(new FollowCommand($follower1->authorId()->id, $authorId));
($followCommandHandler)(new FollowCommand($follower2->authorId()->id, $authorId));
($followCommandHandler)(new FollowCommand($follower3->authorId()->id, $authorId));
$followersCounter = new class() {
private int $count = 0;
public function increment(): void
{
++$this->count;
}
public function getCount(): int
{
return $this->count;
}
};
$this->redis->incr(Argument::type('string'))->will(function () use($followersCounter): int {
$followersCounter->increment();
return $followersCounter->getCount();
});
$this->redis->get(Argument::type('string'))->will(static fn() => $followersCounter->getCount());
$this->redis->exists(Argument::type('string'))->willReturn(true);
$events = Type\vec(
Type\instance_of(AuthorWasFollowed::class)
)->coerce($eventBus->getEvents());
Iter\apply($events, ($this->authorWasFollowedEventHandler)(...));
$totalNumberOfFollowers = ($this->countFollowersQueryHandler)(
new CountFollowersQuery($authorId)
);