Przejdź do treści

Zadanie 2. Obsługa Feature Flag

Implementacja Feature Flag

Dodamy do naszej aplikacji obsługę feature flag, tworząc klasy, które pozwolą nam na:

  • statyczny dostęp do stanu flag z dowolnego miejsca w kodzie
  • niestatyczny dostęp do stanu flag tam, gdzie będziemy chcieli mieć implementację jako zależność
  • obsługę stanu domyślnego feature flag z poziomu kodu
  • obsługę stanu feature flag z pomocą zmiennych środowiskowych

Na początek interfejs

main/src/FeatureFlags/FeatureFlagsInterface.php
1
2
3
4
5
6
7
8
<?php
namespace Tbd\Main\FeatureFlags;

interface FeatureFlagsInterface
{
    public function setEnabled(string $flag, bool $enabled = true) : void;
    public function isEnabled(string $flag) : bool;
}

Prosta implementacja naszego interfejsu

main/src/FeatureFlags/InMemoryFeatureFlags.php
<?php
namespace Tbd\Main\FeatureFlags;

class InMemoryFeatureFlags implements FeatureFlagsInterface
{
    private $flags = [];

    public function __construct(array $flags = []){
        $this->flags = $flags;
    }

    public function setEnabled(string $flag, bool $enabled = true) : void
    {
        $this->flags[$flag] = $enabled;
    }

    public function isEnabled(string $flag) : bool
    {
        return isset($this->flags[$flag]) ? $this->flags[$flag] : false;
    }
}

Źródło stanu flag

main/src/Flags.php
1
2
3
<?php
return array(
);

Statyczny helper

main/src/FeatureFlags/FeatureFlag.php
<?php

namespace Tbd\Main\FeatureFlags;

class FeatureFlag
{
    private static FeatureFlagsInterface $instance;

    public static function setFeatureFlags(FeatureFlagsInterface $featureFlags) : void
    {
        self::$instance = $featureFlags;
    }

    public static function isEnabled(string $flag) : bool
    {
        if(isset(self::$instance)) {
            return self::$instance->isEnabled($flag);
        }
        return false;
    }
}

Połączmy całość

main/public/index.php
<?php

use Tbd\Main\FeatureFlags\FeatureFlag;
use Tbd\Main\FeatureFlags\InMemoryFeatureFlags;

require __DIR__ . '/../vendor/autoload.php';

$initialFlags = require __DIR__ . '/../src/Flags.php';
$featureFlags = new InMemoryFeatureFlags($initialFlags);
FeatureFlag::setFeatureFlags($featureFlags);

$app = new \Tbd\Main\Application();
$app->run();

Możliwość nadpisania stanu flag zmiennymi środowiskowymi

main/src/FeatureFlags/EnvOverrider.php
<?php
namespace Tbd\Main\FeatureFlags;

class EnvOverrider
{
    public function overrideFlags(array $flags) : array
    {
        foreach($flags as $flag => $value){
            $env = getenv("FEATURE_FLAG_".strtoupper($flag));
            if($env !== false){
                $flags[$flag] = (bool)$env;
            }
        }
        return $flags;
    }
}

Połączmy całość

main/public/index.php
<?php

use Tbd\Main\FeatureFlags\EnvOverrider;
use Tbd\Main\FeatureFlags\FeatureFlag;
use Tbd\Main\FeatureFlags\InMemoryFeatureFlags;

require __DIR__ . '/../vendor/autoload.php';

$initialFlags = require __DIR__ . '/../src/Flags.php';
$envOverrider = new EnvOverrider();
$featureFlags = new InMemoryFeatureFlags($envOverrider->overrideFlags($initialFlags));
FeatureFlag::setFeatureFlags($featureFlags);

$app = new \Tbd\Main\Application();
$app->run();

Obsługa flag w testach automatycznych

main/tests/bootstrap.php
<?php

use Tbd\Main\FeatureFlags\EnvOverrider;
use Tbd\Main\FeatureFlags\FeatureFlag;
use Tbd\Main\FeatureFlags\InMemoryFeatureFlags;

require_once __DIR__ . '/../vendor/autoload.php';

$initialFlags = require __DIR__ . '/../src/Flags.php';
$envOverrider = new EnvOverrider();
$featureFlags = new InMemoryFeatureFlags($envOverrider->overrideFlags($initialFlags));
FeatureFlag::setFeatureFlags($featureFlags);

Sprawdźmy czy nic nie zepsuliśmy

composer run tests

Pierwsze zastosowanie Feature Flag

Wykorzystamy naszą implementację, by wprowadzić pierwszą zmianę w działaniu aplikacji.

Chcemy, aby kontroler ProductsListController zwracał również opis i cenę produktu, ale zmianę chcemy zamknąć w Feature Flagę.

Response ma dodatkowo zwracać klucze description i price.

1. Nowa flaga

Do tablicy w main/src/Flags.php dodaj klucz

<?php
return array(
    'show_product_details_on_list' => false
);

2. Testy automatyczne

2.1. Zmień nazwę testu ProductListControllerTest::testControllerReturnsValidResponse na ProductListControllerTest::testControllerReturnsValidResponseWithDetailsDisabled.

2.2. Dopisz kod sprawiający, by test został pominięty, gdy flaga jest włączona.

Podpowiedź
<?php
/* ... */
if(FeatureFlag::isEnabled('show_product_details_on_list')){
    $this->markTestSkipped("Flag show_product_details_on_list is enabled");
}

2.3. Uruchom pipeline.sh, by mieć pewność, że wszystko jest ok.

2.4. Commit.

2.5. Stwórz test ProductListControllerTest::testControllerReturnsValidResponseWithDetailsEnabled, który testuje zachowanie aplikacji, gdy flaga jest włączona. Dopisz do niego kod, który pominie test, gdy flaga jest wyłączona.

2.6. Uruchom pipeline.sh, by mieć pewność, że wszystko jest ok.

2.7. Commit.

Rozwiązanie
<?php

namespace Tbd\Main\Tests\Products;

use Tbd\Main\FeatureFlags\FeatureFlag;
use Tbd\Main\Products\Product;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use React\Http\Message\ServerRequest;
use Tbd\Main\Products\ProductRepositoryInterface;
use Tbd\Main\Products\ProductsListController;

class ProductListControllerTest extends TestCase
{
    public function testControllerReturnsValidResponseWithDetailsDisabled()
    {
        if(FeatureFlag::isEnabled('show_product_details_on_list')){
            $this->markTestSkipped("Flag show_product_details_on_list is enabled");
        }

        $request = new ServerRequest('GET', 'http://example.com/products/');

        $product1 = new Product(1, 'test', 'description', 100);
        $product2 = new Product(2, 'test2', 'description2', 200);

        $stub = $this->createMock(ProductRepositoryInterface::class);
        $stub->method('listProducts')
            ->willReturn([$product1, $product2]);

        $controller = new ProductsListController($stub);

        $response = $controller($request);

        $this->assertInstanceOf(ResponseInterface::class, $response);
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));

        $output='[
    {
        "id": 1,
        "name": "test"
    },
    {
        "id": 2,
        "name": "test2"
    }
]';
        $this->assertEquals($output, (string) trim($response->getBody()));
    }

    public function testControllerReturnsValidResponseWithDetailsEnabled()
    {
        if(!FeatureFlag::isEnabled('show_product_details_on_list')){
            $this->markTestSkipped("Flag show_product_details_on_list is disabled");
        }

        $request = new ServerRequest('GET', 'http://example.com/products/');

        $product1 = new Product(1, 'test', 'description', 100);
        $product2 = new Product(2, 'test2', 'description2', 200);

        $stub = $this->createMock(ProductRepositoryInterface::class);
        $stub->method('listProducts')
            ->willReturn([$product1, $product2]);

        $controller = new ProductsListController($stub);

        $response = $controller($request);

        $this->assertInstanceOf(ResponseInterface::class, $response);
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));

        $output='[
    {
        "id": 1,
        "name": "test",
        "description": "description",
        "price": 100.0
    },
    {
        "id": 2,
        "name": "test2",
        "description": "description2",
        "price": 200.0
    }
]';
        $this->assertEquals($output, (string) trim($response->getBody()));
    }

    public function testControllerReturnsEmptyResponse()
    {
        $request = new ServerRequest('GET', 'http://example.com/products/');

        $stub = $this->createMock(ProductRepositoryInterface::class);
        $stub->method('listProducts')
            ->willReturn([]);

        $controller = new ProductsListController($stub);

        $response = $controller($request);

        $this->assertInstanceOf(ResponseInterface::class, $response);
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));

        $output='[]';
        $this->assertEquals($output, (string) trim($response->getBody()));
    }
}

3. Implementacja rozwiązania

3.1. Stwórz kod, który zaimplementuje wymagane zachowanie w aplikacji, który uruchomi się, gdy flaga jest włączona.

3.2. Przetestuj swoje rozwiązanie za pomocą testów automatycznych, uruchamiając je ze zmienną środowiskową, która włączy flagę

FEATURE_FLAG_SHOW_PRODUCT_DETAILS_ON_LIST=1 composer run tests

3.3 Zmodyfikuj pipeline.sh, by uruchamiał testy drugi raz, ale z włączoną flagą

3.4. Uruchom pipeline.sh, by mieć pewność, że wszystko jest ok.

3.5. Commit.