Symfony 구성 요소를 사용한 진정한 종속성 주입

게시 됨: 2022-03-11

고성능 PHP 프레임워크인 Symfony2는 구성 요소가 DI 컨테이너에 대한 종속성 주입 인터페이스를 제공하는 종속성 주입 컨테이너 패턴을 사용합니다. 이렇게 하면 각 구성 요소가 다른 종속성을 신경 쓰지 않아도 됩니다. 'Kernel' 클래스는 DI 컨테이너를 초기화하고 이를 다른 구성 요소에 주입합니다. 그러나 이것은 DI-container를 Service Locator로 사용할 수 있음을 의미합니다.

Symfony2에는 이를 위한 'ContainerAware' 클래스도 있습니다. 많은 사람들이 Service Locator가 Symfony2의 안티 패턴이라는 의견을 가지고 있습니다. 개인적으로 동의하지 않습니다. DI에 비해 패턴이 간단하고 간단한 프로젝트에 적합합니다. 그러나 단일 프로젝트에 결합된 Service Locator 패턴과 DI-container 패턴은 확실히 안티 패턴입니다.

Symfony 구성 요소를 사용한 진정한 종속성 주입

이 기사에서는 Service Locator 패턴을 구현하지 않고 Symfony2 애플리케이션을 빌드하려고 합니다. 우리는 하나의 간단한 규칙을 따를 것입니다: DI-컨테이너 빌더만이 DI-컨테이너에 대해 알 수 있습니다.

DI 용기

Dependency Injection 패턴에서 DI-container는 서비스 종속성을 정의하고 서비스는 주입을 위한 인터페이스만 제공할 수 있습니다. Dependency Injection에 대한 많은 기사가 있으며 아마도 모두 읽었을 것입니다. 따라서 이론에 집중하지 않고 기본 개념을 살펴보겠습니다. DI는 3가지 유형이 될 수 있습니다.

Symfony에서는 간단한 구성 파일을 사용하여 주입 구조를 정의할 수 있습니다. 이 3가지 주입 유형을 구성하는 방법은 다음과 같습니다.

 services: my_service: class: MyClass constructor_injection_service: class: SomeClass1 arguments: ["@my_service"] method_injection_service: class: SomeClass2 calls: - [ setProperty, "@my_service" ] property_injection_service: class: SomeClass3 properties: property: "@my_service"

부트스트랩 프로젝트

기본 응용 프로그램 구조를 만들어 보겠습니다. 그 동안 Symfony DI 컨테이너 구성 요소를 설치할 것입니다.

 $ mkdir trueDI $ cd trueDI $ composer init $ composer require symfony/dependency-injection $ composer require symfony/config $ composer require symfony/yaml $ mkdir config $ mkdir www $ mkdir src

작곡가 자동 로더가 src 폴더에서 우리 자신의 클래스를 찾도록 하기 위해 우리는 composer.json 파일에 'autoloader' 속성을 추가할 수 있습니다:

 { // ... "autoload": { "psr-4": { "": "src/" } } }

그리고 컨테이너 빌더를 만들고 컨테이너 주입을 금지합시다.

 // in src/TrueContainer.php use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\ContainerInterface; class TrueContainer extends ContainerBuilder { public static function buildContainer($rootPath) { $container = new self(); $container->setParameter('app_root', $rootPath); $loader = new YamlFileLoader( $container, new FileLocator($rootPath . '/config') ); $loader->load('services.yml'); $container->compile(); return $container; } public function get( $id, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE ) { if (strtolower($id) == 'service_container') { if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE !== $invalidBehavior ) { return; } throw new InvalidArgumentException( 'The service definition "service_container" does not exist.' ); } return parent::get($id, $invalidBehavior); } }

여기에서는 Config 및 Yaml symfony 구성 요소를 사용합니다. 여기에서 공식 문서에서 자세한 내용을 찾을 수 있습니다. 또한 만일을 대비하여 루트 경로 매개변수 'app_root'를 정의했습니다. get 메소드는 상위 클래스의 기본 get 동작을 오버로드하고 컨테이너가 "service_container"를 반환하지 못하도록 합니다.

다음으로 애플리케이션에 대한 진입점이 필요합니다.

 // in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));

이것은 http 요청을 처리하기 위한 것입니다. 콘솔 명령, cron 작업 등을 위한 더 많은 진입점을 가질 수 있습니다. 각 진입점은 특정 서비스를 받아야 하며 DI 컨테이너 구조에 대해 알고 있어야 합니다. 컨테이너에서 서비스를 요청할 수 있는 유일한 장소입니다. 이 순간부터 DI 컨테이너 구성 파일만 사용하여 이 애플리케이션을 빌드하려고 합니다.

HttpKernel

HttpKernel(서비스 로케이터 문제가 있는 프레임워크 커널이 아님)은 응용 프로그램의 웹 부분에 대한 기본 구성 요소입니다. 다음은 일반적인 HttpKernel 워크플로입니다.

녹색 사각형은 이벤트입니다.

HttpKernel은 Request 및 Response 개체에 HttpFoundation 구성 요소를 사용하고 이벤트 시스템에 EventDispatcher 구성 요소를 사용합니다. DI 컨테이너 구성 파일로 초기화하는 데 문제가 없습니다. HttpKernel은 EventDispatcher, ControllerResolver 및 선택적으로 RequestStack(하위 요청용) 서비스로 초기화되어야 합니다.

다음은 이에 대한 컨테이너 구성입니다.

 # in config/events.yml services: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher
 # in config/kernel.yml services: request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] request_stack: class: Symfony\Component\HttpFoundation\RequestStack resolver: class: Symfony\Component\HttpKernel\Controller\ControllerResolver http_kernel: class: Symfony\Component\HttpKernel\HttpKernel arguments: ["@dispatcher", "@resolver", "@request_stack"]
 #in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' }

보시다시피 'factory' 속성을 사용하여 요청 서비스를 생성합니다. HttpKernel 서비스는 Request 객체만 받고 Response 객체를 반환합니다. 전면 컨트롤러에서 수행할 수 있습니다.

 // in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $HTTPKernel = $container->get('http_kernel'); $request = $container->get('request'); $response = $HTTPKernel->handle($request); $response->send();

또는 'factory' 속성을 사용하여 구성에서 응답을 서비스로 정의할 수 있습니다.

 # in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]

그런 다음 전면 컨트롤러에서 가져옵니다.

 // in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();

컨트롤러 확인 서비스는 컨트롤러를 확인하기 위해 요청 서비스의 속성에서 '_controller' 속성을 가져옵니다. 이러한 속성은 컨테이너 구성에서 정의할 수 있지만 간단한 배열 대신 ParameterBag 개체를 사용해야 하기 때문에 조금 더 까다로워 보입니다.

 # in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, \App\Controller\DefaultController::defaultAction ]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...

그리고 여기 defaultAction 메소드가 있는 DefaultController 클래스가 있습니다.

 // in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Response; class DefaultController { function defaultAction() { return new Response("Hello cruel world"); } }

이 모든 것이 준비되면 작동하는 애플리케이션이 있어야 합니다.

이 컨트롤러는 서비스에 액세스할 수 없기 때문에 매우 쓸모가 없습니다. Symfony 프레임워크에서 이 문제는 컨트롤러에 DI 컨테이너를 삽입하고 이를 서비스 로케이터로 사용하여 해결됩니다. 우리는 그렇게하지 않을 것입니다. 컨트롤러를 서비스로 정의하고 요청 서비스를 주입해 보겠습니다. 구성은 다음과 같습니다.

 # in config/controllers.yml services: controller.default: class: App\Controller\DefaultController arguments: [ "@request"]
 # in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, ["@controller.default", defaultAction ]]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
 #in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' }

그리고 컨트롤러 코드:

 // in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class DefaultController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction() { $name = $this->request->get('name'); return new Response("Hello $name"); } }

이제 컨트롤러는 요청 서비스에 액세스할 수 있습니다. 보시다시피 이 체계에는 순환 종속성이 있습니다. DI 컨테이너는 생성 후 메서드 및 속성 주입 전에 서비스를 공유하기 때문에 작동합니다. 따라서 컨트롤러 서비스가 생성될 때 요청 서비스는 이미 존재합니다.

작동 방식은 다음과 같습니다.

그러나 이것은 요청 서비스가 먼저 생성되기 때문에 작동합니다. 프론트 컨트롤러에서 응답 서비스를 받을 때 요청 서비스는 첫 번째로 초기화된 종속성입니다. 컨트롤러 서비스를 먼저 얻으려고 하면 순환 종속성 오류가 발생합니다. 메서드 또는 속성 주입을 사용하여 수정할 수 있습니다.

그러나 또 다른 문제가 있습니다. DI 컨테이너는 종속성을 사용하여 각 컨트롤러를 초기화합니다. 따라서 필요하지 않더라도 존재하는 모든 서비스를 초기화합니다. 다행히 컨테이너에는 지연 로딩 기능이 있습니다. Symfony DI 구성 요소는 프록시 클래스에 'ocramius/proxy-manager'를 사용합니다. 우리는 그들 사이에 다리를 설치해야 합니다.

 $ composer require symfony/proxy-manager-bridge

그리고 컨테이너 구축 단계에서 정의합니다.

 // in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...

이제 우리는 게으른 서비스를 정의할 수 있습니다.

 # in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]

따라서 컨트롤러는 실제 메서드가 호출될 때만 종속 서비스를 초기화합니다. 또한 실제 초기화 전에 컨트롤러 서비스가 공유되기 때문에 순환 종속성 오류를 방지합니다. 우리는 여전히 순환 참조를 피해야 합니다. 이 경우 요청 서비스에 컨트롤러 서비스를 주입하거나 컨트롤러 서비스에 요청 서비스를 주입해서는 안 됩니다. 분명히 컨트롤러에서 요청 서비스가 필요하므로 컨테이너 시작 단계에서 요청 서비스에서 주입을 방지합시다. HttpKernel에는 이를 위한 이벤트 시스템이 있습니다.

라우팅

분명히 우리는 다른 요청에 대해 다른 컨트롤러를 원합니다. 그래서 라우팅 시스템이 필요합니다. symfony 라우팅 구성 요소를 설치해 보겠습니다.

 $ composer require symfony/routing

라우팅 구성 요소에는 라우팅 구성 파일을 사용할 수 있는 라우터 클래스가 있습니다. 그러나 이러한 구성은 Route 클래스의 키-값 매개변수일 뿐입니다. Symfony 프레임워크는 'ContainerAware' 인터페이스를 사용하여 컨트롤러에 컨테이너를 주입하는 FrameworkBundle의 자체 컨트롤러 해석기를 사용합니다. 이것이 바로 우리가 피하려고 하는 것입니다. HttpKernel 컨트롤러 리졸버는 '_controller' 속성에 이미 컨트롤러 객체와 액션 메소드 문자열이 있는 배열로 클래스 객체를 반환합니다(실제로 컨트롤러 리졸버는 배열인 경우 그대로 반환합니다). 따라서 각 경로를 서비스로 정의하고 여기에 컨트롤러를 삽입해야 합니다. 작동 방식을 확인하기 위해 다른 컨트롤러 서비스를 추가해 보겠습니다.

 # in config/controllers.yml # ... controller.page: lazy: true class: App\Controller\PageController arguments: [ "@request"]
 // in src/App/Controller/PageController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class PageController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction($id) { return new Response("Page $id doesn't exist"); } }

HttpKernel 컴포넌트에는 'kernel.request' 이벤트를 사용하는 RouteListener 클래스가 있습니다. 다음은 게으른 컨트롤러로 가능한 구성입니다.

 # in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction']
 # in config/routing.yml imports: - { resource: 'routes/default.yml' } services: route.collection: class: Symfony\Component\Routing\RouteCollection calls: - [ add, ["route_home", "@route.home"] ] - [ add, ["route_page", "@route.page"] ] router.request_context: class: Symfony\Component\Routing\RequestContext calls: - [ fromRequest, ["@request"] ] router.matcher: class: Symfony\Component\Routing\Matcher\UrlMatcher arguments: [ "@route.collection", "@router.request_context" ] router.listener: class: Symfony\Component\HttpKernel\EventListener\RouterListener arguments: matcher: "@router.matcher" request_stack: "@request_stack" context: "@router.request_context"
 # in config/events.yml service: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher calls: - [ addSubscriber, ["@router.listener"]]
 #in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' }

또한 애플리케이션에 URL 생성기가 필요합니다. 여기있어:

 # in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"

URL 생성기는 컨트롤러 및 렌더링 서비스에 주입할 수 있습니다. 이제 기본 응용 프로그램이 있습니다. 다른 서비스는 구성 파일이 특정 컨트롤러나 이벤트 디스패처에 주입되는 것과 같은 방식으로 정의할 수 있습니다. 예를 들어 다음은 Twig 및 Doctrine에 대한 몇 가지 구성입니다.

작은 가지

Twig는 Symfony2 프레임워크의 기본 템플릿 엔진입니다. 많은 Symfony2 구성 요소는 어댑터 없이도 사용할 수 있습니다. 따라서 우리 응용 프로그램에 대한 명백한 선택입니다.

 $ composer require twig/twig $ mkdir src/App/View
 # in config/twig.yml services: templating.twig_loader: class: Twig_Loader_Filesystem arguments: [ "%app_root%/src/App/View" ] templating.twig: class: Twig_Environment arguments: [ "@templating.twig_loader" ]

교의

Doctrine은 Symfony2 프레임워크에서 사용되는 ORM입니다. 다른 ORM을 사용할 수 있지만 Symfony2 구성 요소는 이미 많은 Docrine 기능을 사용할 수 있습니다.

 $ composer require doctrine/orm $ mkdir src/App/Entity
 # in config/doctrine.yml parameters: doctrine.driver: "pdo_pgsql" doctrine.user: "postgres" doctrine.password: "postgres" doctrine.dbname: "true_di" doctrine.paths: ["%app_root%/src/App/Entity"] doctrine.is_dev: true services: doctrine.config: class: Doctrine\ORM\Configuration factory: [ Doctrine\ORM\Tools\Setup, createAnnotationMetadataConfiguration ] arguments: paths: "%doctrine.paths%" isDevMode: "%doctrine.is_dev%" doctrine.entity_manager: class: Doctrine\ORM\EntityManager factory: [ Doctrine\ORM\EntityManager, create ] arguments: conn: driver: "%doctrine.driver%" user: "%doctrine.user%" password: "%doctrine.password%" dbname: "%doctrine.dbname%" config: "@doctrine.config"
 #in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' } - { resource: 'twig.yml' } - { resource: 'doctrine.yml' }

주석 대신 YML 및 XML 매핑 구성 파일을 사용할 수도 있습니다. 'createYAMLMetadataConfiguration' 및 'createXMLMetadataConfiguration' 메서드를 사용하고 이러한 구성 파일이 있는 폴더의 경로를 설정하기만 하면 됩니다.

모든 컨트롤러에 필요한 모든 서비스를 개별적으로 주입하는 것은 매우 성가신 일이 될 수 있습니다. 조금 더 좋게 하기 위해 DI-container 컴포넌트는 추상 서비스와 서비스 상속을 가지고 있습니다. 따라서 몇 가지 추상 컨트롤러를 정의할 수 있습니다.

 # in config/controllers.yml services: controller.base_web: lazy: true abstract: true class: App\Controller\Base\WebController arguments: request: "@request" templating: "@templating.twig" entityManager: "@doctrine.entity_manager" urlGenerator: "@router.generator" controller.default: class: App\Controller\DefaultController parent: controller.base_web controller.page: class: App\Controller\PageController parent: controller.base_web
 // in src/App/Controller/Base/WebController.php namespace App\Controller\Base; use Symfony\Component\HttpFoundation\Request; use Twig_Environment; use Doctrine\ORM\EntityManager; use Symfony\Component\Routing\Generator\UrlGenerator; abstract class WebController { /** @var Request */ protected $request; /** @var Twig_Environment */ protected $templating; /** @var EntityManager */ protected $entityManager; /** @var UrlGenerator */ protected $urlGenerator; function __construct( Request $request, Twig_Environment $templating, EntityManager $entityManager, UrlGenerator $urlGenerator ) { $this->request = $request; $this->templating = $templating; $this->entityManager = $entityManager; $this->urlGenerator = $urlGenerator; } } // in src/App/Controller/DefaultController // … class DefaultController extend WebController { // ... } // in src/App/Controller/PageController // … class PageController extend WebController { // ... }

Form, Command 및 Assets와 같은 다른 유용한 Symfony 구성 요소가 많이 있습니다. 이들은 독립적인 구성요소로 개발되었으므로 DI-container를 사용한 통합은 문제가 되지 않을 것입니다.

태그

DI 컨테이너에는 태그 시스템도 있습니다. 태그는 Compiler Pass 클래스에서 처리할 수 있습니다. Event Dispatcher 구성 요소에는 이벤트 리스너 구독을 단순화하기 위해 자체 Compiler Pass가 있지만 EventDispatcher 클래스 대신 ContainerAwareEventDispatcher 클래스를 사용합니다. 그래서 우리는 그것을 사용할 수 없습니다. 그러나 이벤트, 경로, 보안 및 기타 목적을 위해 자체 컴파일러 패스를 구현할 수 있습니다.

예를 들어 라우팅 시스템에 대한 태그를 구현해 보겠습니다. 이제 경로를 정의하려면 config/routes 폴더의 경로 구성 파일에서 경로 서비스를 정의한 다음 config/routing.yml 파일의 경로 수집 서비스에 추가해야 합니다. 한 곳에서는 라우터 매개변수를 정의하고 다른 곳에서는 라우터 이름을 정의하기 때문에 일관성이 없어 보입니다.

태그 시스템을 사용하면 태그에 경로 이름을 정의하고 이 경로 서비스를 태그 이름을 사용하여 경로 컬렉션에 추가할 수 있습니다.

DI 컨테이너 구성 요소는 컴파일러 패스 클래스를 사용하여 실제 초기화 전에 컨테이너 구성을 수정합니다. 라우터 태그 시스템에 대한 컴파일러 패스 클래스를 구현해 보겠습니다.

 // in src/CompilerPass/RouterTagCompilerPass.php namespace CompilerPass; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; class RouterTagCompilerPass implements CompilerPassInterface { /** * You can modify the container here before it is dumped to PHP code. * * @param ContainerBuilder $container */ public function process(ContainerBuilder $container) { $routeTags = $container->findTaggedServiceIds('route'); $collectionTags = $container->findTaggedServiceIds('route_collection'); /** @var Definition[] $routeCollections */ $routeCollections = array(); foreach ($collectionTags as $serviceName => $tagData) $routeCollections[] = $container->getDefinition($serviceName); foreach ($routeTags as $routeServiceName => $tagData) { $routeNames = array(); foreach ($tagData as $tag) if (isset($tag['route_name'])) $routeNames[] = $tag['route_name']; if (!$routeNames) continue; $routeReference = new Reference($routeServiceName); foreach ($routeCollections as $collection) foreach ($routeNames as $name) $collection->addMethodCall('add', array($name, $routeReference)); } } }
 // in src/TrueContainer.php //... use CompilerPass\RouterTagCompilerPass; // ... $container = new self(); $container->addCompilerPass(new RouterTagCompilerPass()); // ...

이제 구성을 수정할 수 있습니다.

 # in config/routing.yml # … route.collection: class: Symfony\Component\Routing\RouteCollection tags: - { name: route_collection } # ...
 # in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] tags: - { name: route, route_name: 'route_home' } route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction'] tags: - { name: route, route_name: 'route_page' }

보시다시피 서비스 이름 대신 태그 이름으로 경로 컬렉션을 가져오므로 경로 태그 시스템은 실제 구성에 의존하지 않습니다. 또한 'add' 메소드를 사용하여 모든 수집 서비스에 경로를 추가할 수 있습니다. 컴파일러 전달자는 종속성 구성을 크게 단순화할 수 있습니다. 그러나 DI 컨테이너에 예기치 않은 동작을 추가할 수 있으므로 인수, 메서드 호출 또는 클래스 이름 변경과 같은 기존 논리를 수정하지 않는 것이 좋습니다. 태그를 사용하여 수행한 것처럼 기존에 새 항목을 추가하기만 하면 됩니다.

마무리

이제 DI 컨테이너 패턴만 사용하는 애플리케이션이 있으며 DI 컨테이너 구성 파일만 사용하여 빌드됩니다. 보시다시피 이런 방식으로 Symfony 애플리케이션을 구축하는 데 심각한 문제는 없습니다. 또한 모든 애플리케이션 종속성을 간단히 시각화할 수 있습니다. 사람들이 DI-container를 서비스 로케이터로 사용하는 유일한 이유는 서비스 로케이터 개념이 이해하기 더 쉽기 때문입니다. 그리고 서비스 로케이터로 사용되는 DI 컨테이너가 있는 거대한 코드 기반은 아마도 그 이유의 결과일 것입니다.

GitHub에서 이 애플리케이션의 소스 코드를 찾을 수 있습니다.