Closed4

ソースコードリーディング①(Laravel)

Kotaro SuzukiKotaro Suzuki

今回コードリーディングするものは下記
stefanzweifel/screeenly

なぜこれを選んだか

  • 使い慣れたPHP(Laravel)で書かれている
  • スクリーンショットのAPIを提供するサービスなので仕様もそこまで難しくない
  • そこまで大きなプロジェクトではない
  • starも400程付いているので、コードも信頼できる
Kotaro SuzukiKotaro Suzuki

Interface

  • Screeenly\Contracts配下にinterfaceを格納
  • 関数はcaptureのみ
  • 返り値はここでは定義されていないがScreeenly\Entities\Screenshotが返却される
modules/Screeenly/Contracts/CanCaptureScreenshot.php
<?php

namespace Screeenly\Contracts;

use Screeenly\Entities\Url;

interface CanCaptureScreenshot
{
    public function capture(Url $url, $storageUrl);
}

  • interfaceの実装先
  • ChromeBrowserは外部パッケージの「Spatie\Browsershot」を使用 * github
modules/Screeenly/Services/ChromeBrowser.php
 <?php

namespace Screeenly\Services;

use Illuminate\Support\Facades\Storage;
use Screeenly\Contracts\CanCaptureScreenshot;
use Screeenly\Entities\Screenshot;
use Screeenly\Entities\Url;
use Spatie\Browsershot\Browsershot;

class ChromeBrowser extends Browser implements CanCaptureScreenshot
{
    public function capture(Url $url, $filename)
    {
        $browser = Browsershot::url($url->getUrl())
            ->ignoreHttpsErrors()
            ->windowSize($this->width, is_null($this->height) ? 768 : $this->height)
            ->timeout(30)
            ->setDelay($this->delay * 1000)
            ->userAgent('screeenly-bot 2.0');


        if (config('screeenly.disable_sandbox')) {
            $browser->noSandbox();
        }

        if (is_null($this->height)) {
            $browser->fullPage();
        }

        Storage::disk(config('screeenly.filesystem_disk'))->put($filename, $browser->screenshot());

        $path = Storage::disk(config('screeenly.filesystem_disk'))->path($filename);

        return new Screenshot($path);
    }
}

modules/Screeenly/Services/AwsBrowser.php
<?php

namespace Screeenly\Services;

use Illuminate\Support\Facades\Storage;
use Screeenly\Contracts\CanCaptureScreenshot;
use Screeenly\Entities\Screenshot;
use Screeenly\Entities\Url;
use Wnx\SidecarBrowsershot\BrowsershotLambda;

class AwsBrowser extends Browser implements CanCaptureScreenshot
{
    public function capture(Url $url, $filename)
    {
        $browser = BrowsershotLambda::url($url->getUrl())
            ->ignoreHttpsErrors()
            ->windowSize($this->width, is_null($this->height) ? 768 : $this->height)
            ->timeout(30)
            ->setDelay($this->delay * 1000)
            ->userAgent('screeenly-bot 2.0');


        if (config('screeenly.disable_sandbox')) {
            $browser->noSandbox();
        }

        if (is_null($this->height)) {
            $browser->fullPage();
        }

        Storage::disk(config('screeenly.filesystem_disk'))->put($filename, $browser->screenshot());

        $path = Storage::disk(config('screeenly.filesystem_disk'))->path($filename);

        return new Screenshot($path);
    }
}

  • ServiceProviderを継承したScreeenlyServiceProviderクラスにてinterfaceをbind
  • configのuse_aws_lambda_browserの値がtrueであればCanCaptureScreenshot::classをAwsBrowser::classに注入。use_aws_lambda_browserがfalseであれば、CanCaptureScreenshot::classを ChromeBrowser::classに注入
modules/Screeenly/Providers/ScreeenlyServiceProvider.php
<?php

namespace Screeenly\Providers;

use Illuminate\Support\ServiceProvider;
use Screeenly\Contracts\CanCaptureScreenshot;
use Screeenly\Guards\ScreeenlyTokenGuard;
use Screeenly\Services\AwsBrowser;
use Screeenly\Services\ChromeBrowser;

class ScreeenlyServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {
        $this->app['view']->addNamespace('screeenly', base_path().'/modules/Screeenly/Resources/views');

        if (config('screeenly.use_aws_lambda_browser') === true) {
            $this->app->bind(CanCaptureScreenshot::class, AwsBrowser::class);
        } else {
            $this->app->bind(CanCaptureScreenshot::class, ChromeBrowser::class);
        }

        auth()->extend('screeenly-token', function ($app, $name, array $config) {
            return new ScreeenlyTokenGuard(
                auth()->createUserProvider($config['provider']),
                $this->app['request']
            );
        });
    }

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

  • config/screeenly.phpの中身
  • 環境変数「SCREEENLY_USE_AWS_LAMBDA_BROWSER」はデフォルトではfalseが格納されている。よってCanCaptureScreenshotはデフォルトではChromeBrowserに注入される
config/screeenly.php
<?php

return [

    /*
     * Disable Chrome Sandbox
     * See https://github.com/stefanzweifel/screeenly/issues/174#issuecomment-423438612
     */
    'disable_sandbox' => env('SCREEENLY_DISABLE_SANDBOX', false),

    /**
     * The Filesystem disk where screenshots are being stored
     */
    'filesystem_disk' => env('SCREEENLY_DISK', 'public'),


    'use_aws_lambda_browser' => env('SCREEENLY_USE_AWS_LAMBDA_BROWSER', false),
];

  • Contracts\CanCaptureScreenshotのクライアント
  • CaptureService.phpはContracts\CanCaptureScreenshotの実装先を知らない(具象ではなく抽象に依存)
  • 依存関係逆転の法則(DIP)
modules/Screeenly/Services/CaptureService.php
<?php

namespace Screeenly\Services;

use Illuminate\Support\Str;
use Screeenly\Contracts\CanCaptureScreenshot;
use Screeenly\Entities\Url;

class CaptureService
{
    /**
     * @var Screeenly\Entities\Url
     */
    protected $url;

    /**
     * @var Screeenly\Services\Browser
     */
    protected $browser;

    public function __construct(CanCaptureScreenshot $browser)
    {
        $this->browser = $browser;
    }

    /**
     * Set Height.
     * @param  int $height
     * @return Screeenly\Services\CaptureService
     */
    public function height($height)
    {
        $this->browser->height($height);

        return $this;
    }

    /**
     * Set Width, defaults to 100%.
     * @param  int $width
     * @return Screeenly\Services\CaptureService
     */
    public function width($width)
    {
        $this->browser->width($width);

        return $this;
    }

    /**
     * Set Delay in milliseconds, defaults to 1000.
     * @param  int $delay
     * @return Screeenly\Services\CaptureService
     */
    public function delay($delay)
    {
        $this->browser->delay($delay);

        return $this;
    }

    /**
     * Set Url to capture.
     * @param  Screeenly\Models\Url    $url
     * @return Screeenly\Services\CaptureService
     */
    public function url(Url $url)
    {
        $this->url = $url;

        return $this;
    }

    /**
     * Trigger capture action.
     * @return Screeenly\Entities\Screenshot
     */
    public function capture()
    {
        $filename = uniqid().'_'.Str::random(30) . ".png";

        return $this->browser->capture(
            $this->url,
            $filename
        );
    }
}

  • AwsBrowser, ChromeBrowserの親クラス
  • AwsBrowser, ChromeBrowserで使用される、共通メソッドを定義
  • 定義されているメソッドの内容はsetter、値のバリーデーションのみ
modules/Screeenly/Services/Browser.php
<?php

namespace Screeenly\Services;

use Exception;

class Browser
{
    /**
     * @var int
     */
    public $height = null;

    /**
     * @var int
     */
    public $width = 1024;

    /**
     * @var int
     */
    public $delay = 1;

    /**
     * Set Height.
     * @param  int $height
     * @return Screeenly\Services\Browser
     */
    public function height($height = 100)
    {
        $this->height = $height;

        return $this;
    }

    /**
     * Set Width, defaults to 100%.
     * @param  int $width
     * @return Screeenly\Services\Browser
     */
    public function width($width = 100)
    {
        if ($width > 5000) {
            throw new Exception('Screenshot width can not exceed 5000 Pixels');
        }
        $this->width = $width;

        return $this;
    }

    /**
     * Set Delay in miliseconds, defaults to 1000.
     * @param  int $delay
     * @return Screeenly\Services\Browser
     */
    public function delay($delay = 1000)
    {
        if ($delay > 15000) {
            throw new Exception('Delay can not exceed 15 seconds / 15000 miliseconds');
        }

        $this->delay = $delay;

        return $this;
    }
}

  • テストではInMemoryBrowserがバインドされる
tests/modules/Screeenly/integration/api/v2/ApiV2ScreenshotTest.php
<?php

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Storage;
use Screeenly\Contracts\CanCaptureScreenshot;
use Screeenly\Models\ApiKey;
use Screeenly\Services\InMemoryBrowser;

class ApiV2ScreenshotTest extends BrowserKitTestCase
{
    use DatabaseTransactions;
    use InteractsWithBrowser;

    /** @test */
    public function it_shows_error_message_if_no_api_key_is_provided()
    {
        $this->json('POST', '/api/v2/screenshot', [])
            ->seeJson([
                'error' => 'Unauthenticated.',
            ]);
    }

    /** @test */
    public function it_shows_error_if_no_url_is_provied()
    {
        $apiKey = ApiKey::factory()->create();

        $this->json('POST', '/api/v2/screenshot', [
            'key' => $apiKey->key,
        ])
            ->seeJson([
                'url' => ['The url field is required.'],
            ]);
    }

    /** @test */
    public function it_shows_error_if_not_a_url_is_passed()
    {
        $apiKey = ApiKey::factory()->create();

        $this->json('POST', '/api/v2/screenshot', [
            'key' => $apiKey->key,
            'url' => 'Foo',
        ])
            ->seeJson([
                'url' => ['The url format is invalid.', 'The url is not a valid URL.'],
            ]);
    }

    /** @test */
    public function it_returns_base64_representation_of_screenshot()
    {
        Storage::fake(config('screeenly.filesystem_disk'));

        Storage::disk(config('screeenly.filesystem_disk'))
            ->put(
                'test-screenshot.jpg',
                file_get_contents(storage_path('testing/test-screenshot.jpg'))
            );

        $apiKey = ApiKey::factory()->create();

        $this->app->bind(CanCaptureScreenshot::class, function ($app) {
            return new InMemoryBrowser('http://foo.bar', '/path/to/storage');
        });

        $this->json('POST', '/api/v2/screenshot', [
            'key' => $apiKey->key,
            'url' => 'http://google.com',
        ])
        ->seeJsonStructure([
            'data' => [
                'path', 'base64',
            ],
        ]);
    }
}
modules/Screeenly/Services/InMemoryBrowser.php
<?php

namespace Screeenly\Services;

use Screeenly\Contracts\CanCaptureScreenshot;
use Screeenly\Entities\Screenshot;
use Screeenly\Entities\Url;

class InMemoryBrowser extends Browser implements CanCaptureScreenshot
{
    /**
     * Capture Url and store image in given Path.
     * @param  Url    $url
     * @param  string $storageUrl
     * @return Screeenly\Entities\Screenshot
     */
    public function capture(Url $url, $storageUrl)
    {
        return new Screenshot(storage_path('testing/test-screenshot.jpg'));
    }
}

altテキスト
依存関係

Kotaro SuzukiKotaro Suzuki

Service

  • ScreenshotControllerのコンストラクタにてCaptureServiceをDI
  • 前述の通りCaptureServiceはコンストラクタでContract/CanCaptureScreenshotをDIしている
  • デフォルトではChromeBrowserが注入されているので、ここではChromeBrowserのcaputure()が実行される
ScreenshotController
<?php

namespace Screeenly\Http\Controllers\Api\v2;

use App\Http\Controllers\Controller;
use Screeenly\Entities\Url;
use Screeenly\Http\Requests\CreateScreenshotRequest;
use Screeenly\Models\ApiKey;
use Screeenly\Services\CaptureService;

class ScreenshotController extends Controller
{
    /**
     * @var Screeenly\Services\CaptureService
     */
    protected $captureService;

    public function __construct(CaptureService $captureService)
    {
        $this->captureService = $captureService;
    }

    /**
     * Create a new Screenshot.
     * @param  CreateScreenshotRequest $request
     * @return Illuminate\Http\Response
     */
    public function store(CreateScreenshotRequest $request)
    {
        $apiKey = ApiKey::where('key', $request->key)->first();

        $screenshot = $this->captureService
                        ->height($request->get('height', null))
                        ->width($request->get('width', null))
                        ->delay($request->get('delay', 1))
                        ->url(new Url($request->url))
                        ->capture();

        $apiKey->apiLogs()->create([
            'user_id' => $apiKey->user->id,
            'images' => $screenshot->getPath(),
            'ip_address' => $request->ip(),
        ]);

        return response()->json([
            'data' => [
                'path' => $screenshot->getPublicUrl(),
                'base64' => $screenshot->getBase64(),
            ],
        ]);
    }
}

alt
依存関係

Kotaro SuzukiKotaro Suzuki

Entity

  • コンストラクタでバリデーションしている
Url
<?php

namespace Screeenly\Entities;

use Exception;

class Url
{
    /**
     * @var string
     */
    protected $url;

    public function __construct($url)
    {
        $this->url = $url;

        $this->isValid();
    }

    /**
     * Return the sanitized Url.
     * @return string
     */
    public function getUrl()
    {
        return $this->url;
    }

    /**
     * Test if the passed URL has a valid format.
     * @return bool
     */
    protected function isValid()
    {
        if (! filter_var($this->url, FILTER_VALIDATE_URL)) {
            throw new Exception("The URL {$this->url} is invalid.");
        }
    }
}

  • CanCaptureのcaputure()でこのEntityが返却される
  • コンストラクタの引数にスクリーンショットの保存先のstorage pathが渡される
Screenshot
<?php

namespace Screeenly\Entities;

use Exception;
use Illuminate\Support\Facades\Storage;

class Screenshot
{
    /**
     * @var string
     */
    protected $base64;

    /**
     * @var string
     */
    protected $path;

    /**
     * @var string
     */
    protected $filename;

    /**
     * @var string
     */
    protected $publicUrl;

    /**
     * Screenshot constructor.
     * @param $absolutePath
     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
     */
    public function __construct($absolutePath)
    {
        $this->doesScreenshotExist($absolutePath);
        $this->path = $absolutePath;
        $this->filename = basename($absolutePath);
        $this->publicUrl = asset(Storage::disk(config('screeenly.filesystem_disk'))->url($this->filename));
        $this->base64 = base64_encode(Storage::disk(config('screeenly.filesystem_disk'))->get($this->filename));
    }

    /**
     * Return base64 representation of the Screenshot.
     * @return string
     */
    public function getBase64()
    {
        return $this->base64;
    }

    /**
     * Return the filename of the Screenshot.
     * @return string
     */
    public function getFilename()
    {
        return $this->filename;
    }

    /**
     * @return string
     */
    public function getPath()
    {
        return $this->path;
    }

    /**
     * Return the public Url to the screenshot image.
     * @return string
     */
    public function getPublicUrl()
    {
        return $this->publicUrl;
    }

    /**
     * Test if a file is available.
     * @param string $absolutePath
     * @return void
     * @throws Exception
     */
    protected function doesScreenshotExist(string $absolutePath)
    {
        if (config('screeenly.filesystem_disk') == 'public') {
            if (file_exists($absolutePath) == false) {
                throw new Exception("Screenshot can't be generated for given URL");
            }
        } else {
            if (Storage::disk(config('screeenly.filesystem_disk'))->exists($absolutePath) == false) {
                throw new Exception("Screenshot can't be generated for given URL");
            }
        }
    }

    /**
     * Delete Screenshot File from Storage.
     * @return bool
     */
    public function delete()
    {
        return Storage::disk(config('screeenly.filesystem_disk'))->delete($this->filename);
    }
}
このスクラップは2022/11/01にクローズされました