소개

Wandu는 Full-Stack Framework입니다.

Wandu는 그저 뼈대만을 제공할 뿐, 내부의 모든 패키지는 다 대체 가능한 형태로 제작되어있습니다. 만약에 이미 진행중인 프로젝트가 있다면, Wandu의 모든 패키지를 그대로 가져다가 사용하는 것도 가능합니다. 특히, 오래된 레거시 소스를 현대의 PHP로 리팩토링하고자 할 때, 쉽게 접근할 수 있는 프레임워크입니다.

브릿지패키지

Wandu는 모든 부분이 대체가 가능합니다. 만약에, 템플릿 엔진으로서 Twig를 사용하고 싶으면 Twig를 사용하면 됩니다. Latte를 사용하고 싶다면 Latte를 사용하면 됩니다. 데이터베이스 엔진도 마찬가지입니다. Eloquent를 사용하고 싶다면 Eloquent를, Doctrin ORM을 사용하고 싶다면 그렇게 하시면 됩니다. 그리고 그 외에 패키지들도 마찬가지입니다. (물론, Adapter를 제작해야 할 때도 있습니다.)

기본으로 브릿지 소스를 제공하는 패키지들은 다음과 같습니다.

템플릿엔진

데이터베이스

로그

설치하기

Wandu는 패키지 분할을 통해 마이크로프레임워크(Micro Framework) 처럼 사용할 수 있도록 작성되었습니다. 그치만, 문서는 기본으로 풀스택으로 설치하였다는 가정하에 다루고 있습니다.

풀스택 프레임워크로 사용하기

Composer를 통해서 설치할 수 있습니다.

cd /your/project/path
composer require wandu/framework

설치후에 vendor/bin 디렉토리에 있는 wandu를 통해 install명령어를 실행할 수 있습니다.

vendor/bin/wandu install

그러면 두개의 질문을 물어봅니다. 첫번째는 설치경로, 두번째는 사용할 어플리케이션의 네임스페이스입니다. 첫번째 질문은 특수한 상황이 아니라면 그냥 기본값을 사용하면 됩니다.

 install path? [/your/project/path]:
 >

두번째 질문은 어플리케이션에서 사용할 네임스페이스입니다. 원하는 값을 입력하세요. 그리고 이왕이면 PSR-4 규칙에 따라서, PascalCase로 작성해주시기 바랍니다. 문서에서는 기본값인 Wandu\App을 그대로 사용했다는 전제하에 진행하겠습니다.

 app namespace? [Wandu\App]:
 > 

모든 설치가 끝났습니다.

public/index.php 파일이 보이시나요? 이제 이 파일을 통해서 어플리케이션을 실행할 수 있습니다.

php -S 127.0.0.1:8080 -t public

위 명령어를 통해서 어플리케이션을 실행해보세요. 그리고 브라우저에 127.0.0.1:8080을 입력해보세요. 정상적으로 설치되었다면 Hello Wandu라는 메시지가 출력됩니다.

둘러보기

.
├ app
│  ├ commands.php
│  ├ config.php
│  ├ providers.php
│  └ routes.php
├ cache
├ migrations
├ public
│  ├ favicon.ico
│  ├ index.php
│  └ robots.txt
├ src
│  ├ Http
│  │  └ Controllers
│  │     └ HelloWorldController.php
│  ├ ApplicationDefinition.php
│  └ ApplicationServiceProvider.php
└ views
    └ welcome.php
  • app/commands.php : 커맨드라인 명령어가 정의되어있습니다.
  • app/config.php : 모든 설정이 정의되어있습니다.
  • app/providers.php : 사용할 서비스 프로바디어가 정의되어있습니다.
  • app/routes.php : 웹에서 사용할 라우팅 내용이 정의되어있습니다.

  • cache : 캐시 디렉토리

  • migrations : 데이터베이스 마이그레이션이 정의되어있습니다.

  • public/index.php : 웹프로그램 시작점입니다.

  • src/Http/Controller/HelloWorldController.php : 기본 헬로월드 컨트롤러입니다.
  • src/ApplicationDefinition.php : 설정, 서비스프로바이더를 제공하는 파일입니다.
  • src/ApplicationServiceProvider.php : 현재 앱의 서비스 프로바이더

  • views/welcome.php : 기본 헬로월드 뷰 (템플릿 사용 안함)

앱 정의하기

설치 후, 생성된 .wandu.php파일을 열어보면 Wandu\Foundation\Contracts\DefinitionInterface 인터페이스를 상속받은 클래스 객체를 반환하는 것을 볼 수 있습니다.

<?php
return new Wandu\App\ApplicationDefinition();

해당 클래스를 열어보면 크게 2개로 구분된 것을 볼 수 있습니다. 이를 통해 Wandu Framework는 어플리케이션 전체를 정의할 수 있습니다.

  • configs()
  • providers()

각각이 하는 일은 단순합니다.

Config

configs()는 앱의 설정을 읽어들입니다. 이 설정은 배열의 형태를 유지하고 있습니다. 그래서 내부에 있는 각종 클래스에서 해당 값을 사용할 수 있습니다.

Provider

providers()는 앱의 서비스프로바이더를 읽어들입니다. 만약에 이후에 다른 라이브러리를 설치한 경우 해당 영역에 서비스프로바이더를 추가하여야 합니다.

정리하자면, .wandu.php파일을 통해서 공통으로 사용되는 값(Config), 공통으로 사용되는 클래스 및 함수(Providers)을 정의합니다.

앱 서비스프로바이더

어플리케이션에서 사용할 내용을 주입하는 앱 서비스 프로바이더입니다.

<?php
namespace Wandu\App;

use Wandu\DI\ContainerInterface;
use Wandu\DI\ServiceProviderInterface;
use Wandu\Foundation\Contracts\HttpErrorHandlerInterface;
use Wandu\Foundation\Contracts\KernelInterface;
use Wandu\Foundation\Error\DefaultHttpErrorHandler;

class ApplicationServiceProvider implements ServiceProviderInterface
{
    public function register(ContainerInterface $app)
    {
        $app->bind(HttpErrorHandlerInterface::class, DefaultHttpErrorHandler::class);
    }

    public function boot(ContainerInterface $app)
    {
        if ($app->has(KernelInterface::class)) {
            $kernel = $app->get(KernelInterface::class);
            $kernel['commands'] = require __DIR__ . '/../app/commands.php';
            $kernel['routes'] = require __DIR__ . '/../app/routes.php';
        }
    }
}

라우터(Router)

Wandu의 라우터는 PHP에서 가장 빠른 Fast Route라는 라이브러리를 기반으로 하고 있습니다. 그리고, PHP FIG에서 책정한 표준 PSR-7을 준수하고 있습니다.

정의하기

/app/routes.php의 파일을 통해서 쉽게 정의할 수 있습니다.

기본적으로 RESTful 메서드 전체를 지원하고 있습니다.

<?php

// ...생략...

public function routes(Router $router)
{
    $router->get('/users', UserController::class, 'index');
    $router->post('/users', UserController::class, 'store');
    
    $router->get('/users/{id:\d+}', UserController::class, 'show');
    $router->put('/users/{id:\d+}', UserController::class, 'update');
    $router->delete('/users/{id:\d+}', UserController::class, 'destroy');
}

// ...생략...

그리고 optionspatch도 메서드로 제공하고 있습니다.

get, post, put, delete, options, patch는 3개의 매개변수를 요구합니다. 첫번째는 URL 규칙이고, 두번째는 컨트롤러 클래스 마지막 세번째는 컨트롤러 클래스의 메서드입니다. 3번째 매개변수는 생략가능하며 기본값은 index입니다.

주의하세요!

Wandu Router는 기본적으로 굉장히 Url에 예민한(Sensitive) 라우터입니다. 첫번째 매개변수에 /users를 앞에 슬래시(/)를 생략하면 동작하지 않을 수 있습니다. 이 예민한 특성을 이용해서 다음과 같은 Url 규칙도 생성할 수 있습니다.

<?php

// ...생략...

public function routes(Router $router)
{
    $router->get('/users', UserController::class, 'index');

    $router->get('//users', UserController::class, 'indexOther1'); // 달라요!!
    $router->get('///users', UserController::class, 'indexOther2'); // 전혀 달라요!!
}

// ...생략...

사용자 정의 메서드 구현하기

만약에 Varnish와 같은 도구를 사용하는데 PURGE라는 메서드의 라우트 룰을 정의하고 싶을 수 있습니다. 그럴때는 다음과 같이 정의할 수 있습니다.

<?php

// ...생략...

public function routes(Router $router)
{
    $router->createRoute(['PURGE'], '/users', UserController::class, 'purge');
    $router->createRoute(['ETC', 'CUSTOM_METHOD'], '/users', UserController::class, 'something');
}

// ...생략...

Prefix 정의하기

반복되는 부분을 매번 정의할 때 번거로우면 하나로 합칠 수 있습니다.

<?php

// ...생략...

public function routes(Router $router)
{
    $router->get('/users', UserController::class, 'index');
    $router->post('/users', UserController::class, 'store');
    
    $router->get('/users/{id:\d+}', UserController::class, 'show');
    $router->put('/users/{id:\d+}', UserController::class, 'update');
    $router->delete('/users/{id:\d+}', UserController::class, 'destroy');
}

// ...생략...

위 소스는 다음과 같이 교체할 수 있습니다.

<?php

// ...생략...

public function routes(Router $router)
{
    $router->prefix('/users', function (Router $router) {
        $router->get('', UserController::class, 'index');
        $router->post('', UserController::class, 'store');
        
        $router->get('/{id:\d+}', UserController::class, 'show');
        $router->put('/{id:\d+}', UserController::class, 'update');
        $router->delete('/{id:\d+}', UserController::class, 'destroy');
    });
}

// ...생략...

미들웨어(Middleware)

미들웨어를 사용할 수 있습니다. 미들웨어 객체는 Wandu\Router\Contracts\MiddlewareInterface를 구현하면 됩니다. 다음은 세션을 사용하기 위한 Sessionify 미들웨어의 예시입니다.

<?php
namespace Wandu\Router\Middleware;

use Closure;
use Psr\Http\Message\ServerRequestInterface;
use Wandu\Http\Cookie\CookieJarFactory;
use Wandu\Http\Exception\HttpException;
use Wandu\Http\Session\SessionFactory;
use Wandu\Router\Contracts\MiddlewareInterface;

class Sessionify implements MiddlewareInterface
{
    /** @var \Wandu\Http\Cookie\CookieJarFactory */
    protected $cookieJarFactory;

    /** @var \Wandu\Http\Session\SessionFactory */
    protected $sessionFactory;

    /**
     * @param \Wandu\Http\Cookie\CookieJarFactory $cookieJarFactory
     * @param \Wandu\Http\Session\SessionFactory $sessionFactory
     */
    public function __construct(
        CookieJarFactory $cookieJarFactory,
        SessionFactory $sessionFactory
    ) {
        $this->cookieJarFactory = $cookieJarFactory;
        $this->sessionFactory = $sessionFactory;
    }

    /**
     * {@inheritdoc}
     */
    public function __invoke(ServerRequestInterface $request, Closure $next)
    {
        $cookieJar = $this->cookieJarFactory->fromServerRequest($request);
        $session = $this->sessionFactory->fromCookieJar($cookieJar);

        $request = $request
            ->withAttribute('cookie', $cookieJar)
            ->withAttribute('session', $session);

        // run next
        try {
            $response = $next($request);
        } catch (HttpException $exception) {
            $response = $exception->getResponse();
            $this->sessionFactory->toCookieJar($session, $cookieJar);
            $exception->setResponse($this->cookieJarFactory->toResponse($cookieJar, $response));
            throw $exception;
        }

        $this->sessionFactory->toCookieJar($session, $cookieJar);
        return $this->cookieJarFactory->toResponse($cookieJar, $response);
    }
}

위 미들웨어(Middleware)는 다음과 같이 사용할 수 있습니다. 그룹으로 묶어서 사용할 수 있고, 단 하나의 라우터에 정의해서 사용할 수 있습니다.

<?php
use Wandu\Router\Middleware\Sessionify;

// ...생략...

public function routes(Router $router)
{
	$router->middleware(Sessionify::class, function (Router $router) {
        // .. 다른 라우터 정의 ..
    });

    // or

    $router->get('/something', SomeController::class, 'thing', [
    	Sessionify::class,
    ]); // use in only one pattern
}

// ...생략...

Prefix와 함께 사용하기

group이라는 매서드를 통해서 middlewareprefix를 함께 사용할 수 있습니다.

<?php
// ...생략...

public function routes(Router $router)
{
    $router->group([
        'middleware' => Sessionify::class,
        'prefix' => '/something'
    ], function (Router $router) {
    	// .. 다른 라우터 정의 ..
    });
}

컨트롤러(Controller)

컨트롤러는 별다른 제약사항이 없습니다. 일반 클래스를 사용하면 됩니다.

<?php

// ...생략...

public function routes(Router $router)
{
    $router->get('/users', UserController::class, 'index');
    $router->post('/users', UserController::class, 'store');
    
    $router->get('/users/{id:\d+}', UserController::class, 'show');
    $router->put('/users/{id:\d+}', UserController::class, 'update');
    $router->delete('/users/{id:\d+}', UserController::class, 'destroy');
}

// ...생략...

만약 위와 같이 UserController를 사용하기로 했다면, 컨트롤러는 다음과 같이 정의할 수 있습니다.

<?php
use Psr\Http\Message\ServerRequestInterface;
use function Wandu\Http\response;

class UserController
{
    public function index(ServerRequestInterface $request)
    {
        return response()->create('index', 200); // PSR7 ResponseInterface
    }

    public function store(ServerRequestInterface $request)
    {
        return [
            'result' => true,
            'message' => 'store success!',
        ]; // json ok (if router use WanduResponsifier)
    }

    public function show(ServerRequestInterface $request)
    {
        $id = $request->getAttribute('id'); // {id:\d+}
        return 'show'; // string ok (if router use WanduResponsifier)
    }

    public function update(ServerRequestInterface $request)
    {
        $id = $request->getAttribute('id');
        foreach (range(0, 10) as $index) {
            yield $index;
        }
        // generator ok (if router use WanduResponsifier)
    }

    public function destroy(ServerRequestInterface $request)
    {
        $id = $request->getAttribute('id');
        return response()->create('destroy', 200);
    }
}

컨트롤러(Controller)에서 생성자와 모든 매서드는 Wandu의 컨테이너(Wandu DI)를 사용할 수 있도록 설정되어있습니다. 내부적으로 라우터는 Class Loader를 사용하는데, 이때 기본값이 WanduLoader로 되어있습니다. 이 Class Loader가 Wandu 컨테이너를 호출하는 역할을 합니다.

<?php

class UserController
{
    public function __construct(SomethingOtherPackage $package)
    {
        $this->package = $package;
    }

    public function store(UserRepository $users, ServerRequestInterface $request)
    {
        // 생략
    }
}

별도 패키지로 사용하기

Wandu의 라우터는 별도의 패키지로 사용할 수 있도록 지원하고 있습니다.

composer install wandu/router

별도 패키지에서 라우터는 PSR7 기반의 패키지를 사용중이어야 합니다. 사용방법은 다음과 같습니다.

<?php
use Wandu\Router\ClassLoader\DefaultLoader;
use Wandu\Router\Configuration;
use Wandu\Router\Dispatcher;
use Wandu\Router\Exception\HandlerNotFoundException;
use Wandu\Router\Exception\MethodNotAllowedException;
use Wandu\Router\Exception\RouteNotFoundException;
use Wandu\Router\Responsifier\NullResponsifier;
use Wandu\Router\Router;

// class loader는 라우터에서 정의한 클래스와 매서드를 불러옵니다.
// class loader will bring up the classes and methods defined in the router.
// 1. DefaultLoader
// 2. ArrayAccessLoader
// 3. WanduLoader : based on wandu/di package. (wandu default)
$classLoader = new DefaultLoader();

// responsifier는 일반 스칼라 변수를 Response 객체로 바꾸는 일을 합니다.
// responsifier turns scalar variable such as `string`, `int`, to the Response object.
// 1. NullResponsifier
// 2. WanduResponsifier : based on wandu/http package. (wandu default)
$responifier = new NullResponsifier();

$dispatcher = new Dispatcher(
    $classLoader,
    $responsifier,
    new Configure([
        'virtual_method_enabled' => true, // _method=PUT
        'cache_disabled' => false,
        'cache_file' => __DIR__ . '/routes.cache.php',
    ])
);

$dispatcher = $dispatcher->withRouter(function (Router $router) {
    $router->prefix('/admin', function (Router $router) {
        $router->get('/pages', PageController::class, 'index');
        $router->get('/users', UserController::class, 'index');
        $router->get('/users/{user}', UserController::class, 'show');
    });
});

try {
    $response = $dispatcher->dispatch($request); // $request is PSR7 ServerRequestInterface
} catch (MethodNotAllowedException $e) {
    // reutrn 405
} catch (RouteNotFoundException $e) {
    // return 404
} catch (HandlerNotFoundException $e) {
    // return 500
} catch (Exception $e) {
    // return 500    
} catch (Throwable $e) {
    // return 500
}