diff --git a/.castor/docs/best-practices.md b/.castor/docs/best-practices.md index b9bd482..c580c23 100644 --- a/.castor/docs/best-practices.md +++ b/.castor/docs/best-practices.md @@ -1,14 +1,74 @@ Best Practices ============== -## Pass `Context` as the first argument of the function or method +The `Context` api is definitely a new pattern and approach to solve the context passing problem in PHP. +Whenever a new pattern is introduced, is always important to define some best practices for its good use. -## Don't overuse `Context\with_value` for related values +These are some of what we consider to be those best practices. -## Use Enums as Keys +## Custom functions SHOULD be defined to work with `Context` -## Do not store `Context` inside objects +Context is all about composition, and you should also compose the base context functions to provide a nicer api +to store or retrieve context values, like we did in the logger example. Of course, you could also rely on +static methods, but in my personal opinion, functions are nicer. -## Avoid immutable values inside `Context` +You can even go as far as to create your own `Context` implementation that composes a `Context`. We do that for some +of our libraries. But we even discourage that. The thinner you can keep `Context` api surface, the better. -## Do not store services inside the `Context` \ No newline at end of file +## `Context` SHOULD be the first argument of the function or method + +Whenever a function or method call accepts a `Context`, this should be the first argument of the function, and +it should be named `$ctx`. We know some people hate abbreviations and prefer more explicit names. I agree with that +suggestion almost in every situation, but in this case the abbreviation does not yield any sort of confusion and +is pretty universal and easily understood. + +It also has the benefit that it is short, and since `Context` is there to be added to your public api, we want +to minimize as much as possible the space impact that has in your method's argument list. + +## Enums SHOULD be used as keys rather than strings + +When calling `Context\with_value()` prefer enums as keys. They are lightweight, offer autocompletion, and they cannot +collide like strings could. In the case that your application still does not support PHP 8.1 and above, you MUST use +string keys with a vendor namespace. + +## `Context` SHOULD NOT be stored inside other data structures + +Always explicitly pass `Context`. Do not store it inside objects or arrays, unless is explicitly necessary. For +instance, if you are using the `Context` api in PSR-7 applications, it is very likely your `Context` instance +will be stored in the Request attributes (which is implemented by an array). This is acceptable, but for a better +HTTP layer supporting context natively, we recommend `castor/http`. + +## `Context\with_value` SHOULD NOT be overused + +Because of its particular implementation, every time you add a value to a `Context`, you increase the potential call +stack size to reach the value by 1. Although the performance impact of this is negligent, is still slower than fetching +a value directly from a map, for instance. + +So, bottom line, don't overuse `Context\with_value`. This means that if you have to store related values in +`Context`, store a data structure instead and not each value individually. + +Again, the performance impact of not doing this is negligible, so measure and make decisions based on that. + +> In a potential new major version, we are considering swapping the `Context\with_value()` implementation by using a +[`DS\Map` if the extension is available][https://www.php.net/manual/en/class.ds-map.php] to avoid the performance +penalty. + +## `Context` SHOULD NOT contain immutable values + +A `Context` implementation is already immutable, and it's lifetime does not go outside the request. This means it is +safe for manipulation and free of unexpected side effects. + +As long as `Context` holds values derived from the request, whose lifetime will also die with it, then it is safe to +store mutable values in it. If you store immutable values and decide that a new reference of that value needs to be +passed down the call stack it means the value should have never been immutable in the first place. You'll have to call +`Context\with_value` again and "override" that value. + +## Services SHOULD NOT be stored inside `Context` + +We greatly discourage storing services inside `Context`. It is not a good idea to mix request-scoped values with +application scoped-values like services. Always prefer dependency injection instead of passing services down the +context. + +There could be some use cases when this could make sense. For instance, if you need to pass an event loop instance +down the call stack to reach some method and make it work asynchronously. I would say that is part of the execution +context, although an event loop can be classified as a service. \ No newline at end of file diff --git a/.castor/docs/faq.md b/.castor/docs/faq.md index 3b691c1..3734ad8 100644 --- a/.castor/docs/faq.md +++ b/.castor/docs/faq.md @@ -9,4 +9,11 @@ let me use any type as key. Arrays only support integers and strings. ## 2. Should I use it in my domain? Yes! It is designed for this very purpose. Once you add the `Context` interface as a type hint of a repository -method or some other function, a world of possibilities are opened in terms of evolvavility and extensibility. \ No newline at end of file +method or some other function, a world of possibilities are opened in terms of evolvavility and extensibility. + +## 3. Will this package ever have breaking changes? + +No. Context promises a stable api since is really one of the most important building blocks of our libraries. + +However, we are considering expanding the api surface for a possible `v2` version once we have implemented async +libraries, and we decide we need cancellation signals, similar to what Golang context api has at the minute. \ No newline at end of file diff --git a/.castor/docs/guides.md b/.castor/docs/guides.md index 43a9cac..aaef260 100644 --- a/.castor/docs/guides.md +++ b/.castor/docs/guides.md @@ -3,7 +3,457 @@ Guides & Examples ## Implementing Multi-Tenancy +A common use case for many applications is to support multi-tenancy. Multi tenancy is the ability of an application +to operate for multiple tenants or customers using the same instance and codebase. + +Now we are going to see an example of how we can implement multi tenancy using the context api. + +Let's suppose our imaginary application determines the tenant based on the subdomain, in the HTTP layer, in some +middleware: + +```php +#next = $next; +# $this->tenants = $tenants; +# } +# + public function handle(Context $ctx, ResponseWriter $wrt, Request $req): void + { + $hostname = $req->getUri()->getHostname(); + // This should separate subdomain.domain.tld + $parts = explode('.', $hostname, 3); + + // No subdomain + if (count($parts) <= 2) { + $this->next->handle($ctx, $wrt, $req); + return; + } + + $tenantName = $parts[0] ?? ''; + + try { + $tenant = $this->tenants->ofId($ctx, $id); + } catch (NotFound) { + $this->next->handle($ctx, $wrt, $req); + return; + } + + // Once we have the tenant, we store it in the context + $ctx = Context\with_value($ctx, 'tenant', $tenant); + + // And we pass it to the next handler + $this->next->handle($ctx, $wrt, $req); + } +#} +``` + +So, you have "captured" some important information about the context in which the request is being made, which +is the current tenant in use (or no tenant at all if no subdomain is present). + +Now, further layers of the application that are interested in this piece of information, can modify their behaviour +based on the absence or presence of such value. + +For instance, we want to list all the users, but only those of the current tenant: + +```php +#query = $query; +# } +# + public function all(Context $ctx): array + { + $query = $this->query->select()->from('users'); + + $tenant = $ctx->value('tenant'); + if ($tenant instanceof Tenant) { + $query = $query->where('tenant_id = ?', $tenant->getId()); + } + + return $query->execute(); + } +#} +``` + +The context interface provides several benefits in this case. It's transparency means you could deploy +this for a single tenant and the application would work exactly the same. You could even implement lazy +database connection based on the tenant to point them to different databases. + +Possibilities are endless. + ## Passing Logger Context -## Template Params +Another common problem in PHP applications is the passing of context for log calls. Say, for example, you want all your +logs to contain the `request_id` property, so you can group them together and explore all the logs emitted by a single +request, but you don't want to pass the request id to every single call that could reach a logger. That would be a +terrible thing to do. What you want here is the power of context. + +In this short guide, we'll explore more advanced things you can do with context. First, let's create a simple object +to hold the state we want to accumulate to later send to the logger. + +```php +data = []; + } + + public function add(string $key, mixed $value): LogContext + { + $this->data[$key] = $value; + return $this; + } + + public function merge(array $data): LogContext + { + $this->data = array_merge($this->data, $data); + return $this; + } + + public function toArray(): array + { + return $this->data; + } +} +``` + +This is the class that we are going to store in the context. It does not need to be immutable, because Context is +immutable and every context instance will have its own instance of this class. There is no possibility that that instance +will be shared outside the scope of the request, so it is safe. Immutability is good in certain contexts, but +in this case it is not needed. We want this class to be mutable *by design*. + +Now, we will provide a simple set of functions on top of the context functions, to ease the manipulation of this +class. Note these are pure functions: passed the same input, they will yield the same output. + +```php +data = []; +# } +# +# public function add(string $key, mixed $value): LogContext +# { +# $this->data[$key] = $value; +# return $this; +# } +# +# public function merge(array $data): LogContext +# { +# $this->data = array_merge($this->data, $data); +# return $this; +# } +# +# public function toArray(): array +# { +# return $this->data; +# } +#} + +// First we create an enum for the key +enum Key +{ + case LOG_CONTEXT +} + +// This function add an entry to the stored LogContext +function with_log_context(Context $ctx, string $key, mixed $value): Context +{ + $logCtx = $ctx->value(Key::LOG_CONTEXT); + + // If we have an instance already, we mutate that, and we don't touch the context + if ($logCtx instanceof LogContext) { + $logCtx->add($key, $value); + return $ctx; + } + + // We mutate the context only if we don't have a LogContext object in it + $logCtx = new LogContext(); + $logCtx->add($key, $value); + + return Context\with_value($ctx, Key::LOG_CONTEXT, $logCtx); +} + +function get_log_context(Context $ctx): LogContext +{ + // We try to not throw exceptions but provide defaults instead. + return $ctx->value(Key::LOG_CONTEXT) ?? new LogContext(); +} +``` + +Note how these functions make it easy to add this particular bit of information to the context. They also +hide the need for client code to know the context key, and they take sensible decisions for client code. The functions +are pure, and with no side effects. + +Now, let's work in the middleware that will capture our request: + +```php +data = []; +# } +# +# public function add(string $key, mixed $value): LogContext +# { +# $this->data[$key] = $value; +# return $this; +# } +# +# public function merge(array $data): LogContext +# { +# $this->data = array_merge($this->data, $data); +# return $this; +# } +# +# public function toArray(): array +# { +# return $this->data; +# } +#} +# +#// First we create an enum for the key +#enum Key +#{ +# case LOG_CONTEXT +#} +# +#// This function add an entry to the stored LogContext +#function with_log_context(Context $ctx, string $key, mixed $value): Context +#{ +# $logCtx = $ctx->value(Key::LOG_CONTEXT); +# +# // If we have an instance already, we mutate that, and we don't touch the context +# if ($logCtx instanceof LogContext) { +# $logCtx->add($key, $value); +# return $ctx; +# } +# +# // We mutate the context only if we don't have a LogContext object in it +# $logCtx = new LogContext(); +# $logCtx->add($key, $value); +# +# return Context\with_value($ctx, Key::LOG_CONTEXT, $logCtx); +#} +# +#function get_log_context(Context $ctx): LogContext +#{ +# // We try to not throw exceptions but provide defaults instead. +# return $ctx->value(Key::LOG_CONTEXT) ?? new LogContext(); +#} +# +namespace MyApp\Http; + +use MyApp\Logger; +use Castor\Context; +use Castor\Http\Handler; +use Castor\Http\Request; +use Castor\Http\ResponseWriter; +use Ramsey\Uuid; +use function MyApp\Logger\with_log_context; + +class RequestIdMiddleware implements Handler +{ + private Handler $next; + + public function __construct(Handler $next) + { + $this->next = $next; + } + + public function handle(Context $ctx, ResponseWriter $wrt, Request $req): void + { + $requestId = req->getHeaders()->get('X-Request-Id'); + + // If no request id comes, we assign one + if ($requestId === '') { + $requestId = Uuid::v4(); + } + + $ctx = with_log_context($ctx, 'request_id', $requestId); + $this->next->handle($ctx, $wrt, $req); + } +} +``` + +Then, the only bit left to do is to consume this from the code that calls the logger. + +```php +data = []; +# } +# +# public function add(string $key, mixed $value): LogContext +# { +# $this->data[$key] = $value; +# return $this; +# } +# +# public function merge(array $data): LogContext +# { +# $this->data = array_merge($this->data, $data); +# return $this; +# } +# +# public function toArray(): array +# { +# return $this->data; +# } +#} +# +#// First we create an enum for the key +#enum Key +#{ +# case LOG_CONTEXT +#} +# +#// This function add an entry to the stored LogContext +#function with_log_context(Context $ctx, string $key, mixed $value): Context +#{ +# $logCtx = $ctx->value(Key::LOG_CONTEXT); +# +# // If we have an instance already, we mutate that, and we don't touch the context +# if ($logCtx instanceof LogContext) { +# $logCtx->add($key, $value); +# return $ctx; +# } +# +# // We mutate the context only if we don't have a LogContext object in it +# $logCtx = new LogContext(); +# $logCtx->add($key, $value); +# +# return Context\with_value($ctx, Key::LOG_CONTEXT, $logCtx); +#} +# +#function get_log_context(Context $ctx): LogContext +#{ +# // We try to not throw exceptions but provide defaults instead. +# return $ctx->value(Key::LOG_CONTEXT) ?? new LogContext(); +#} +# +#namespace MyApp\Http; +# +#use MyApp\Logger; +#use Castor\Context; +#use Castor\Http\Handler; +#use Castor\Http\Request; +#use Castor\Http\ResponseWriter; +#use Ramsey\Uuid; +#use function MyApp\Logger\with_log_context +# +#class RequestIdMiddleware implements Handler +#{ +# private Handler $next; +# +# public function __construct(Handler $next) +# { +# $this->next = $next; +# } +# +# public function handle(Context $ctx, ResponseWriter $wrt, Request $req): void +# { +# $requestId = req->getHeaders()->get('X-Request-Id'); +# +# // If no request id comes, we assign one +# if ($requestId === '') { +# $requestId = Uuid::v4(); +# } +# +# $ctx = with_log_context($ctx, 'request_id', $requestId); +# $this->next->handle($ctx, $wrt, $req); +# } +#} + +namespace MyApp\Services; + +use Castor\Context; +use Psr\Log\LoggerInterface; +use function MyApp\Logger\get_log_context; + +class SomeServiceThatLogsStuff +{ + private LoggerInterface $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function doSomething(Context $ctx): void + { + // Do an action and then log it. + // The context holds request_id and other values passed by other layers. + $this->logger->debug('Action completed', get_log_context($ctx)->toArray()); + } +} +``` + +With this simple approach, your log calls will be richer and easier to filter. And you didn't need to +pollute your call stack with a massive class or resort to use globals. Everything is explicit, and by +using the custom functions you make easy for your consumers to extract or insert things from and to the context. +This shows how evolvable the `Context` abstraction is, and how you can compose functionality on top of it by +keeping the interface thin. \ No newline at end of file diff --git a/functions.php b/functions.php index 2da6b47..fca6164 100644 --- a/functions.php +++ b/functions.php @@ -35,16 +35,9 @@ function with_value(Context $context, mixed $key, mixed $value): Context * * You can think about it as an "empty" context. * - * This method always returns the same instance. - * - * @psalm-external-mutation-free + * @psalm-pure */ function fallback(): Context { - static $context = null; - if (null === $context) { - $context = new Value(null); - } - - return $context; + return new Value(); } diff --git a/src/Context/KVPair.php b/src/Context/KVPair.php index a676208..37a4ab7 100644 --- a/src/Context/KVPair.php +++ b/src/Context/KVPair.php @@ -40,6 +40,26 @@ public function __construct(Context $next, mixed $key, mixed $value) $this->value = $value; } + /** + * Returns an array with all the key values stored in the + * context chain, as long as is an interrupted chain of + * KVPair instances. + * + * This function should be used for debugging purposes only. + * + * @return array + */ + public static function debug(Context $ctx): array + { + $chain = []; + while ($ctx instanceof self) { + $chain[] = [$ctx->key, $ctx->value]; + $ctx = $ctx->next; + } + + return $chain; + } + /** * {@inheritDoc} */ diff --git a/src/Context/Value.php b/src/Context/Value.php index 098e8e2..1e168fe 100644 --- a/src/Context/Value.php +++ b/src/Context/Value.php @@ -30,7 +30,7 @@ final class Value implements Context { private mixed $value; - public function __construct(mixed $value) + public function __construct(mixed $value = null) { $this->value = $value; } diff --git a/tests/Context/KVPairTest.php b/tests/Context/KVPairTest.php new file mode 100644 index 0000000..d86af48 --- /dev/null +++ b/tests/Context/KVPairTest.php @@ -0,0 +1,41 @@ +assertSame([ + ['bar', 'foo'], + ['foo', 'bar'], + ], $chain); + } +} diff --git a/tests/ContextFunctionsTest.php b/tests/ContextFunctionsTest.php index 37b1964..6ce4a12 100644 --- a/tests/ContextFunctionsTest.php +++ b/tests/ContextFunctionsTest.php @@ -30,8 +30,8 @@ public function testContextFallback(): void $contextA = Context\fallback(); $contextB = Context\fallback(); - // Fallback should return always the same instance - $this->assertSame($contextA, $contextB); + // Fallback should always return a different instance + $this->assertNotSame($contextA, $contextB); // Context for fallback should always yield null $this->assertNull($contextA->value('foo'));