Laravelでタスクスケジュールを使用するときは、cronの指定が必要

こんにちは。 エキサイト株式会社の三浦です。

PHPの有名なフレームワークであるLaravelには、「タスクスケジュールの実行」、いわゆる「バッチの実行」処理が組み込まれています。

アプリケーションコード内でタスク実行のスケジュールを書けるなど非常に便利なのですが、実は一つ落とし穴があります。

今回は、実際に私が引っかかったその落とし穴の話をしていきます。

タスクスケジュールの設定

Laravelには、タスクスケジュールという機能があります。

readouble.com

Laravelのコマンドスケジューラは、サーバ上でスケジュールするタスクを管理するための新しいアプローチを提供しています。スケジューラを使用すると、Laravelアプリケーション自体の中でコマンドスケジュールを流暢かつ表現力豊かに定義できます。

以下のように記述することで、アプリケーションコード内でタスクのスケジュールをすることができます。

<?php

declare(strict_types=1);

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * Define the application's command schedule.
     */
    protected function schedule(Schedule $schedule): void
    {
        $schedule
            ->command('batch:sample') // あらかじめ batch:sample というコマンドを登録しておく
            ->everyMinute();
    }

    /**
     * Register the commands for the application.
     */
    protected function commands(): void
    {
        $this->load(__DIR__ . '/Commands');

        require base_path('routes/console.php');
    }
}

この、

$schedule
    ->command('batch:sample')
    ->everyMinute();

という記述で、「毎分 batch:sample を実行する」という表現ができました!

あとはLaravelを実行すれば終わり…と思いきや、実はこれだけだとこのバッチは動きません。

タスクスケジュールの実行にはcronの設定が必要

よく見てみると、タスクスケジュールのドキュメントには以下のように書いてあります。

readouble.com

スケジューラを使用する場合、サーバに必要なcronエントリは1つだけです。

実は、「タスクスケジュールを使用する = cronは不要」 ではありません

タスクスケジュールを使用すると、今まではタスクごとに必要だったcronを1つにまとめられるだけで、cron自体は必要なのです。

以下のcronを設定する必要があります。

スケジューラの実行

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

流れとしては、

  1. cronが毎分Laravelを実行
  2. Laravel側で、タスクスケジュールに設定されたコマンドのうち実行タイミングに該当するものを実行

という流れになります。

最後に

これを読み飛ばしてしまい、私はバッチを実行させるのにかなり苦労してしまいました。

フレームワークによっては実際にcronが全く不要なものもある(JavaのSpring Bootなど)ので、そういったものに慣れている人ほど引っかかりやすいかもしれません。

言語やフレームワークの特性によって要不要が変わってくるので、注意していきましょう。

GA4 移行対応 Javaで認証用のJSONを文字列で渡す方法

こんにちは、エキサイト株式会社メディア事業部所属のエンジニア岩藤です。

私は現在、JavaでGoogleAnalytics4(以下GA4)へ移行をおこなっています。

今回はJavaクライアントを使ってのGA4ランキング取得で、 認証用のJSON(Credentials JSON)を環境変数ではなく、文字列で渡す方法を紹介します。

JavaのGA4クライアントについて

切り替えは下記を元に行いました。 developers.google.com github.com

上記のページでは、認証用のJSONを下記の環境変数でファイルパスを指定する事が前提として書かれています。

ここにファイルのパスを入れると勝手に見てくれるようになっています。 ↓

GOOGLE_APPLICATION_CREDENTIALS

動作確認環境

Java

openjdk 17.0.2 2022-01-18
OpenJDK Runtime Environment Temurin-17.0.2+8 (build 17.0.2+8)
OpenJDK 64-Bit Server VM Temurin-17.0.2+8 (build 17.0.2+8, mixed mode)

Gradle

------------------------------------------------------------
Gradle 7.4.1
------------------------------------------------------------

Build time:   2022-03-09 15:04:47 UTC
Revision:     36dc52588e09b4b72f2010bc07599e0ee0434e2e

Kotlin:       1.5.31
Groovy:       3.0.9
Ant:          Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM:          17.0.2 (Eclipse Adoptium 17.0.2+8)
OS:           Mac OS X 12.6.3 aarch64

SpringBoot

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.6.6)

今回やりたかった事

しかし、私の場合は認証用のJSONAWSのパラメータストアで管理していて、そこからJSONを文字列として取得していました。 つまりファイルパスではなく、文字列で渡す必要がありました。

対応方法

BetaAnalyticsDataClientのcreateメソッドに、BetaAnalyticsDataSettingsを渡すようにしました。

String credentialsJson = "{…}";
InputStream credentialsJson = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8));
GoogleCredentials googleCredentials = GoogleCredentials.fromStream(credentialsJson);

BetaAnalyticsDataSettings settings = BetaAnalyticsDataSettings.newBuilder()
    .setTransportChannelProvider(
        BetaAnalyticsDataSettings.defaultHttpJsonTransportProviderBuilder().build()
    )
    .setCredentialsProvider(FixedCredentialsProvider.create(googleCredentials))
    .build();

BetaAnalyticsDataClient client = BetaAnalyticsDataClient.create(settings);

上記の変更により、認証JSONを文字列で渡す事ができ、GA4からレポートが無事取得できました!

GA4では下記のブログも公開しています。併せてご参考ください。 tech.excite.co.jp

【Flutter】Freezedで階層構造のJSONを扱う時ためにやったこと

エキサイトで内定者アルバイトをしている岡島です!

今回はFreezedパッケージを使っていて、階層(入れ子)構造のJSONデータを扱う時につまづいたので解決した対処法を共有します。

Freezedについて

https://pub.dev/packages/freezed

Freezedを使うメリットとして、モデルの定義時にコードの実装量が減り、可読性が上がることが挙げられます。

ドキュメントには以下のような機能の実装が例に挙げられていました。

  • コンストラクタ+プロパティの定義
  • toString, operator ==, hashCode
  • copyWithの実装
  • シリアル化、逆シリアル化
  • unionやパターンマッチングの機能

Freezedではこのような機能を自動生成してくれるので便利です。

階層(入れ子)構造になっている JSONの扱い方

Freezedで、APIレスポンスのJSONや、modelをFreezed化させたい時、階層構造になっていると単純な実装ではエンコード、デコード時にうまくいきません。

そのような場合の対処について、以下のようなJSONを例に挙げて紹介していこうと思います。

{
  "itemName": "扇風機",
  "detail": {
        "price": 10000,
        "description": "心地よい風です。"
  },
}

このように入れ子になっている場合は、ItemクラスとDeteilクラスのファイルを作り解決します。

Itemクラスの中に itemName と detail のプロパティを作り、detailを price と description のプロパティを持たせたクラスにします。こうすることで、ネスト化されたJSONも問題なく扱うことができます。

以下は完成したItemクラスとDetailクラスです。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:my_app/detail.dart';

part 'item.freezed.dart';
part 'item.g.dart';

@freezed
class Item with _$Item {
  factory Item({
    required String itemName,
    required Detail detail,
  }) = _Item;

  factory Item.fromJson(Map<String, dynamic> json) => _$ItemFromJson(json);
}
import 'package:freezed_annotation/freezed_annotation.dart';

part 'detail.freezed.dart';
part 'detail.g.dart';

@freezed
class Detail with _$Detail {
  factory Detail({
    required int price,
    required String description,
  }) = _Detail;

  factory Detail.fromJson(Map<String, dynamic> json) => _$DetailFromJson(json);
}

まとめ

Freezedでネスト化されたJSONデータを扱う時は、クラスを分割することでFreezedでも扱うことができるようになりました。 Freezedを使うことで、FromJson/ToJsonやCopyWithのコードを自動生成してくれるのでコードの記述量が非常に軽減されます。

こちらの記事が参考になれば幸いです。

参考記事

https://pub.dev/packages/freezed https://zenn.dev/xxminamixx/scraps/91c01e8efb5cc1

【Flutter】RawAutocomplete 実装の検索フォームにおける候補ワードを取得する際の状態管理

エキサイト株式会社の@mthiroshiです。

FlutterでRawAutocompleteを使って、テキストの入力に応じて検索候補のワードを表示する、検索フォームを実装してみました。検索候補ワードは、APIから取得します。

下記が公式ドキュメントです。公式ドキュメントには、UIに関するいくつかのサンプルコードが載っています。 api.flutter.dev

実装

ここではViewとViewModel、UiStateのサンプルコードを紹介します。 Android開発におけるUIレイヤーの設計に倣っています。

まずは、UiStateです。 モデル定義のため Freezed を使っています。

part 'demo_search_app_bar_ui_state.freezed.dart';

@freezed
class DemoSearchAppBarUiState with _$DemoSearchAppBarUiState {
  factory DemoSearchAppBarUiState({
    @JsonKey(defaultValue: '') required String inputText,
    required List<String> suggestWords,
  }) = _DemoSearchAppBarUiState;

  const DemoSearchAppBarUiState._();

  factory DemoSearchAppBarUiState.blank() =>
      DemoSearchAppBarUiState(inputText: '', suggestWords: []);
}

UiStateでは、入力テキストの inputText と検索候補ワードリストの suggestWords を持ちます。

次に、ViewModelについてです。データの取得ロジックと状態管理はここで行います。状態管理ライブラリにRivperpodを使います。

part 'demo_search_app_bar_view_model_provider.g.dart';

@riverpod
class DemoSearchAppBarViewModel extends _$DemoSearchAppBarViewModel {
  @override
  Future<DemoSearchAppBarUiState> build() async {
    return DemoSearchAppBarUiState.blank();
  }

  Future<void> suggest(String inputText) async {
    final stateValue = state.requireValue;

    if (inputText.isEmpty) {
      state = await AsyncValue.guard<DemoSearchAppBarUiState>(() async {
        return DemoSearchAppBarUiState.blank();
      });
      return;
    }

    if (inputText == stateValue.inputText) {
      return;
    }

    final suggestWords = await ref
        .read(searchRepositoryProvider)
        .getSuggestKeyword(inputText: inputText);

    state = await AsyncValue.guard<DemoSearchAppBarUiState>(() async {
      return DemoSearchAppBarUiState(
        inputText: inputText,
        suggestWords: suggestWords,
      );
    });
  }
}

VIewModelの suggest()では、Repositoryを呼び出してAPIリクエストをします。 その後、取得したデータをUiStateに保持します。

続いて、Viewのサンプルコードです。

class DemoSearchAppBar extends ConsumerWidget with PreferredSizeWidget {
  DemoSearchAppBar({this.onSubmit, this.onTapTextField, super.key});

  final Future<void> Function(String selected)? onSubmit;

  final Future<void> Function()? onTapTextField;

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final uiState = ref.watch(searchAppBarViewModelProvider);

    return AppBar(
      backgroundColor: Colors.white,
      title: SizedBox(
        height: 50,
        child: uiState.when(
          data: (_) {
            return RawAutocomplete<String>(
              optionsBuilder: (TextEditingValue textEditingValue) async {
                await ref
                    .read(searchAppBarViewModelProvider.notifier)
                    .suggest(textEditingValue.text);

                return ref
                    .read(searchAppBarViewModelProvider)
                    .requireValue
                    .suggestWords;
              },
              optionsViewBuilder: (
                BuildContext context,
                AutocompleteOnSelected<String> onSelected,
                Iterable<String> options,
              ) {
                return Align(
                  alignment: Alignment.topLeft,
                  child: Material(
                    elevation: 4,
                    child: ListView.builder(
                      shrinkWrap: true,
                      padding: const EdgeInsets.all(8),
                      itemCount: options.length,
                      itemBuilder: (BuildContext context, int index) {
                        final String option = options.elementAt(index);
                        return GestureDetector(
                          onTap: () {
                            onSelected(option);
                          },
                          child: PlatformListTile(
                            title: Text(option),
                          ),
                        );
                      },
                    ),
                  ),
                );
              },
              fieldViewBuilder: (
                BuildContext context,
                TextEditingController textEditingController,
                FocusNode focusNode,
                VoidCallback _,
              ) {
                return TextFormField(
                  onTap: () {
                    onTapTextField?.call();
                  },
                  controller: textEditingController,
                  focusNode: focusNode,
                  onFieldSubmitted: (String searchText) async {
                    focusNode.unfocus();
                    await onSubmit?.call(searchText);
                  },
                  decoration: InputDecoration(
                    hintText: '検索ワード',
                    contentPadding: const EdgeInsets.all(8),
                    filled: true,
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(8),
                      borderSide: BorderSide.none,
                    ),
                  ),
                );
              },
              onSelected: (String selection) async {
                final FocusScopeNode currentScope = FocusScope.of(context);
                if (!currentScope.hasPrimaryFocus && currentScope.hasFocus) {
                  FocusManager.instance.primaryFocus!.unfocus();
                }
                await onSubmit?.call(selection);
              },
            );
          },
          error: (_, __) {
            return null;
          },
          loading: () {
            return null;
          },
        ),
      ),
    );
  }
}

最初に、ViewModelProviderをref.watch()して、ViewModelの変更を監視します。

そして、optionsBuilder で検索候補のワードを設定します。 optionsBuilder内のコールバックは、TextFieldに入力がある度に実行されます。 ここで入力中のテキストを受け取り、ViewModelの suggest() を呼び出します。そして、ViewModelのUiStateをref.read()で参照して、optionsに設定しています。

テキスト入力に応じて候補ワードを出す検索フォーム

optionsBuilderのUI反映のタイミングに注意

optionsBuilder内で下記のように監視したUiStateの値を返却値とする場合、更新前の状態がoptionsに反映されてしまいます。

        child: uiState.when(
          data: (state) {
            return RawAutocomplete<String>(
              optionsBuilder: (TextEditingValue textEditingValue) async {
                await ref
                    .read(searchAppBarViewModelProvider.notifier)
                    .suggest(textEditingValue.text);
                
                return state.suggestWords;
             }

UiStateの反映タイミングの遅れ

GIFのように、1操作ズレたUI反映になります。 これは、変更の流れが以下のようになるためです。

  1. テキストを入力して、optionsBuilder のコールバックが発火。
  2. suggest()で取得したデータを基に、UiStateを更新。
  3. optionsBuilder 内で更新前のUiStateをreturnし、コールバックを終了。
  4. UiStateの変更を検知し、Widgetのbuild()が再実行され、UiStateが更新される。

つまり、UiStateの更新がoptionsBuilderのコールバックの終了後に実行されるため、optionsBuilder内のUiStateは前の状態のままとなります。

そのため、optionsBuilder内では、直接UiStateを参照しないと最新の状態を取得できません。

もし、検索候補ワードを状態管理しない設計であれば、optionsBuilder内でAPI取得し、returnするだけなので、この問題は関係ありません。

まとめ

FlutterのRawAutocompleteを使って、テキスト入力に応じて検索候補ワードを表示する検索フォームを実装しました。また、検索候補ワードの状態管理をする際の処理の流れについて説明しました。 殆どの場合、表示に使うデータは状態管理されることが多いので、今回のような処理になると思います。

どなたかの参考になれば幸いです。

参考記事

pub.dev

riverpod.dev

developer.android.com

LaravelのView CreatorでViewテンプレートを切り替える

こんにちは。エキサイト株式会社のあはれんです。

スマートフォンとその他デバイス(パソコン/タブレット)で表示するViewテンプレートを切り替える際の方法として、 LaravelのView Creatorを利用する方法を試してみましたので紹介します。

サンプルコードは以下で公開しています。

github.com

実装方法

1. ユーザーエージェント判定ライブラリを導入

スマートフォンとその他デバイス(パソコン/タブレット)を判定するためのライブラリを導入します。

github.com

composerでインストールします。

composer require jenssegers/agent

config/app.php にプロバイダー登録します。

Jenssegers\Agent\AgentServiceProvider::class,

2. 切り替え対象のViewテンプレートを用意

切り替え対象のViewテンプレートを以下のように用意します。 スマートフォン用のViewテンプレートには _sp というsuffixをつけます。

├── resources
│   └── views
│       ├── pages
│       │   ├── index.blade.php  // その他のデバイス(パソコン/タブレット)用
│       │   └── index_sp.blade.php // スマートフォン用

3. Viewテンプレートの切り替え処理を書いたクラスを作成

Viewテンプレートの切り替え処理を書いたクラスを作成します。

<?php

namespace App\View\Creators;

use Illuminate\View\View;
use Illuminate\View\Factory as ViewFactory;
use Jenssegers\Agent\Agent;

final class ViewSwitchCreator
{
    private const VIEW_NAME_SUFFIX = '_sp';

    public function __construct(
        private readonly Agent $agent,
        private readonly ViewFactory $viewFactory,
    ) {
    }

    public function create(View $view): void
    {
        $viewName = $view->getName();

        if ($this->agent->isMobile() !== true) {
            // spではない場合は置換する必要がないのでスキップ
            return;
        }

        if (str_contains($viewName, self::VIEW_NAME_SUFFIX)) {
            // sp向けviewテンプレートの指定がある場合は置換する必要がないのでスキップ
            return;
        }

        $viewSpName = $viewName . self::VIEW_NAME_SUFFIX;

        // sp向けviewテンプレートがない場合は何もせず、設定済みのviewテンプレートを表示する
        if ($this->viewFactory->exists($viewSpName)) {
            $finder = $this->viewFactory->getFinder();
            $view->setPath($finder->find($viewSpName));
        }
    }
}

3. ViewCreatorを登録

View Creatorを登録するサービスプロバイダを用意します。 ViewCreatorを利用しすべてのViewに切り替え処理が実行されるようにします。

<?php

namespace App\Providers;

use App\View\Creators\ViewSwitchCreator;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;

class ViewCreatorServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        View::creator("*", ViewSwitchCreator::class); // ワイルドカード(*)ですべてのViewに適応
    }
}

config/app.php にプロバイダー登録します。

App\Providers\ViewCreatorServiceProvider::class,

4. 確認ページを用意

確認用のページのコントローラーを用意します。

<?php

namespace App\Http\Controllers\Root;

use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

final class RootIndexGetController extends Controller
{
    public function __construct()
    {
    }

    public function __invoke(Request $request): View
    {
        return view('pages.index');
    }
}

routes/web.php にルートを設定します。

<?php

use App\Http\Controllers\Root\RootIndexGetController;
use Illuminate\Support\Facades\Route;

Route::get('/', RootIndexGetController::class);

該当のページにアクセスするとViewテンプレートが切り替わることを確認できます。

テスト方法

コントローラーのテスト (HTTPのテスト)

コントローラーのテストでは、 ユーザーエージェント情報を込めてリクエストを送り、ユーザーエージェントによって利用されるViewテンプレートが異なることを確認します。

<?php

namespace Tests\Feature\App\Http\Controllers\Root;

use Tests\TestCase;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

class RootIndexGetTest extends TestCase
{
    public const MOBILE_USER_AGENT =
    'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
    public const PC_USER_AGENT =
    'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36';

    public function test_ユーザーエージェントがSPである場合SP用の表示になるべき(): void
    {
        // 1. テストデータの用意
        // 無し

        // 2. テスト対象の実行
        $response = $this->get(
            uri: '/',
            headers: [
                'User-Agent' => self::MOBILE_USER_AGENT,
            ]
        );

        // 3. 検証
        $this->assertSame($response->status(), HttpResponse::HTTP_OK, 'http status codeが200になるべき');

        /**
         * @var View $view
         */
        $view = $response->original;
        $actualViewPath = $view->getPath();
        // ViewSwitchCreatorでPathしか変更しないのでview名を評価するassertViewHasは利用できない。またテストの環境によって絶対Pathが異なるためSP用テンプレートが含まれるかで判定する
        $this->assertStringContainsString('index_sp.blade.php', $actualViewPath);
    }

    public function test_ユーザーエージェントがPCである場合デフォルト用の表示になるべき(): void
    {
        // 1. テストデータの用意
        // 無し

        // 2. テスト対象の実行
        $response = $this->get(
            uri: '/',
            headers: [
                'User-Agent' => self::PC_USER_AGENT,
            ]
        );

        // 3. 検証
        $this->assertSame($response->status(), HttpResponse::HTTP_OK, 'http status codeが200になるべき');
        $response->assertViewIs('pages.index');
    }
}

コントローラー自体にはViewテンプレートの切り替え処理が書かれていないのに切り替えのテストケースが存在する、違和感のあるテストになりました。

また、Viewテンプレートの切り替え処理ではPath変更しておらず、View名は変わらないままなのでassertViewHasが利用できない問題があります。

Viewテンプレートの切り替え処理のテスト

実際にViewテンプレートを操作してテストするのは難しいので、 Viewテンプレートを操作する関数をMockし適切に呼び出せているかで検証しています。

<?php

declare(strict_types=1);

namespace Test\Unit\View\Creators\ViewSwitchCreator;

use Illuminate\View\Factory as ViewFactory;
use Illuminate\View\View;
use Illuminate\View\ViewFinderInterface;
use Mockery;
use Mockery\MockInterface;
use App\View\Creators\ViewSwitchCreator;
use Jenssegers\Agent\Agent;
use Tests\TestCase;

final class ViewSwitchCreatorCreateTest extends TestCase
{
    public function test_ユーザーエージェントがspである場合sp指定のviewのPathに変更されているべき(): void
    {
        // 1. テストデータの用意と 3.検証
        // Illuminate関数を適切に呼び出せているかを検証する

        $spViewName = 'pages.test.index_sp';
        $spViewPath = '/resources/views/pages/test/index_sp.blade.php';

        $view = Mockery::mock(View::class, function (MockInterface $mock) use ($spViewPath) {
            // view名が取得されているべき。返り値はデフォルトのview名。
            $mock->shouldReceive('getName')
                ->once()
                ->andReturn('pages.test.index');

            // sp版のviewのpathを設定するべき。
            $mock->shouldReceive('setPath')
                ->once()
                ->with($spViewPath);
        });

        $viewFinder = Mockery::mock(ViewFinderInterface::class, function (MockInterface $mock) use ($spViewName, $spViewPath) {
            // sp版のview名でファイルPathの検索が行われているべき。
            $mock->shouldReceive('find')
                ->once()
                ->with($spViewName)
                ->andReturn($spViewPath);
        });
        $viewFactory = Mockery::mock(ViewFactory::class, function (MockInterface $mock) use ($spViewName, $viewFinder) {
            // sp版のview名でファイルの存在チェックが行われているべき。
            $mock->shouldReceive('exists')
                ->once()
                ->with($spViewName)
                ->andReturnTrue();

            // Finderインスタンスが取得されているべき。
            $mock->shouldReceive('getFinder')
                ->once()
                ->andReturn($viewFinder);
        });

        $agent = Mockery::mock(Agent::class, function (MockInterface $mock) {
            // ユーザーエージェントによるsp判定処理が行われているべき。
            $mock->shouldReceive('isMobile')
                ->once()
                ->andReturnTrue();
        });

        // 2. テスト対象の実行
        $viewSwitchCreator = new ViewSwitchCreator(
            agent: $agent,
            viewFactory: $viewFactory
        );
        $viewSwitchCreator->create($view);
    }
:

テストコードが長くなったのでブログ内では省略しています。 残りの部分に関しては公開しているサンプルコードで確認ください。

読めないことはないですが読みやすくはないテストコードになりました。 テスト対象がファイル操作なこともあってシンプルに書くのは少し難しかったです。

まとめ

実装してテストまでした結果、 LaravelのView Creatorを利用する方法には以下のメリット・デメリットがあるのではないかと思いました。

  • メリット:View Creatorを使うことで各ページのコントローラーに出し分けの処理を書く手間が省ける。
  • デメリット:コントローラー自体にはないテンプレートの切り替え処理をテストに記述する必要があり直感的でない。また、初めての人がコントローラーを見てもテンプレートが2種類あることが分かりづらい。

なにかの役に立てば幸いです。

スペシャルさんくす

コードレビューをしてくれた @nukisashinekoさん

Javaから、FirebaseのMessagingでpush通知を送る方法

こんにちは。 エキサイト株式会社の三浦です。

アプリにpush通知を送る方法はいくつかありますが、FirebaseのMessaingはその中でも比較的簡単な方法の1つです。

今回は、Java・Spring Bootを使用して、Firebase Messaging経由でアプリにpush通知を送信する方法を紹介します。

事前準備

まずは事前準備として、認証用の秘密鍵を取得します。

Firebaseのコンソールの「プロジェクトの設定」を開きます。

プロジェクトの設定を開く

「サービスアカウント」のタブに移り、「新しい秘密鍵の生成」を行います。

サービスアカウントタブ

なお、この時まだサービスアカウントが作られていない場合は、まず「サービスアカウントを作成」を行ってください。

秘密鍵がダウンロードできたら準備完了です。

なお、これ以外にもアプリ側でpush通知を受け取れるように準備する必要がありますが、今回はサーバ側のみに焦点を当てるため省略します。

アプリケーションコード実装

ではここから、実際にコードを組んでいきます。

まずはSDKをダウンロードします。 今回はGradleを使います。

implementation 'com.google.firebase:firebase-admin:9.1.1'

続いて、Firebaseの初期化を行います。

今回は @Configuration を使って初期化します。

@Configuration
public class FirebaseConfig {
    /**
     * Firebaseの初期化
     *
     * @throws IOException サービスアカウントキーの読み込みに失敗した場合
     */
    @Bean
    public void initFirebase() throws IOException {

        // ダウンロードした秘密鍵。ファイルとして読み込む方法でも可能
        final String serviceAccountKeyString = "***";
        final InputStream serviceAccountKey = new ByteArrayInputStream(serviceAccountKeyString.getBytes(StandardCharsets.UTF_8));

        // 秘密鍵を使ってFirebaseを初期化する
        final FirebaseOptions options = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(serviceAccountKey))
                .build();

        FirebaseApp.initializeApp(options);
    }
}

あとは実際に通知を送るだけです。

public class SamplePushController {

    public void sendPush() throws FirebaseMessagingException {
        // 送信メッセージを作成
        Message message = Message.builder()
                .putData("sample_data", "sample") // アプリ側に送信したいデータを設定
                .putData("sample_data2", "sample2") // 複数設定可能
                .setNotification( // 通知で表示したいデータを設定
                        Notification.builder()
                                .setBody("通知本文")
                                .build()
                )
                .setTopic("sample-topic") // ユーザが購読したトピックに送信
                // .setToken("***") // 個別デバイスに送信することも可能
                .build();

        // 送信
        FirebaseMessaging.getInstance().send(message);
    }
}

これで通知が送信できます。

なお上記で記述した以外に、一度に複数トピック・ユーザに送信できたり、iOSAndroidで別の内容を送ることも出来たりなど色々な設定項目が存在します。

ぜひ公式ドキュメントを見てみてください。

最後に

push通知は、ユーザのエンゲージメントを上げるために有効な手段です。

Firebase Messagingを使って、ぜひ試してみてください!

コンテナでSpring BootのScheduledを使う際は、initialDelayを設定するのが良い

こんにちは。 エキサイト株式会社の三浦です。

Spring Bootには Scheduled というアノテーションがあり、これを使うと定期実行をしてくれるようになります。

今回は、コンテナでこの Scheduled を使うのであれば initialDelay を設定するのが適切だと思った、という話をしていきます。

Scheduledとは

Scheduled は、Spring Bootで定期実行を制御してくれるアノテーションです。

以下のように使用します。

@Scheduled(timeUnit = TimeUnit.MINUTES, fixedDelay = 1)
public void sample()
    // 定期実行したい処理
}

この処理では、「前回の実行終了の1分後に実行する」を繰り返してくれるようになります。

一件問題なさそうですが、コンテナでやるとなると一つ問題が出てきます。

コンテナでScheduledをやる場合は、initialDelayを使うのが良い

コンテナは、サーバに比べて頻繁に終了・起動が起きることを前提としています。

そしてそれは、「起動が失敗する」状況の絶対数がサーバに比べて多くなる可能性を意味しています。

つまり、コンテナでは「コンテナが安定稼働し始めた」ことがわかってから処理を始めるのがより安全ということです。

ですが上記の処理だと、Spring Bootが起動したらすぐ処理が発生してしまうため、万が一起動後すぐにコンテナが落ちてしまった場合問題が起きる可能性があります。

これを解決するには、 initialDelay という設定を使うのが良いでしょう。

@Scheduled(timeUnit = TimeUnit.MINUTES, fixedDelay = 1, initialDelay = 1)
public void sample()
    // 定期実行したい処理
}

例えばこのようにすれば、Spring Bootの起動から1分待ってから最初の処理が始まります。

動くアプリケーション次第で時間の調整は必要ですが、ここで適切な時間を待つようにすることで「コンテナが安定稼働した」ことを担保できるのではないでしょうか。

最後に

Scheduled は、アプリケーションコードで定期実行を管理できるため、コンテナ上で定期実行をするのに極めて便利だと言えます。

一方で、コンテナで定期実行をするのであればコンテナ特有の稼働安定性を考える必要もあります。

適切な設定で、安全なコンテナ上定期実行をしていきましょう。

GA4 移行対応 JavaクライアントでFilterExpressionListを使って条件を複数指定する方法

こんにちは、エキサイト株式会社メディア事業部所属のエンジニアの岩藤です。

Universal Analyticsを利用できるのも後少しですね。

GoogleAnalytics4(以下GA4)へ移行中方も多いのではないでしょうか。 私もランキングプログラムの移行真っ最中です。

今回GA4でランキング生成を行うにあたり、言語もPHP からJavaへの切り替えも行いました。 その際に複数条件を書く方法にハマってしまったので、備忘録を残したいと思います。

切り替えは下記をもとに行いました。 developers.google.com github.com

Betaという名前が気になりますが、とりあえず入れたら動きました。

PVレポートを作るときはURLの絞り込みを行うことが多いと思います。 絞り込みを行う場合は、FilterExpressionクラスを使うようです(下記はurlパターンで絞り込みしてます)

FilterExpression filterExpression = FilterExpression.newBuilder()
    .setFilter(
        Filter.newBuilder()
            .setFieldName("pagePath")
            .setStringFilter(
                Filter.StringFilter.newBuilder()
                    .setMatchType(Filter.StringFilter.MatchType.PARTIAL_REGEXP)
                    .setValue(pathRegex)
            ).build()
        ).build();

RunReportRequest request = RunReportRequest.newBuilder()
    .setProperty("properties/" + propertyId)
    .addDimensions(Dimension.newBuilder().setName("pagePath"))
    .addMetrics(Metric.newBuilder().setName("screenPageViews"))
    .setDimensionFilter(filterExpression)
    .addDateRanges(
        DateRange.newBuilder()
            .setStartDate(startDate)
            .setEndDate(endDate)
      )
     .build();

そして、条件が一つの場合は上記でいいのですが、複数条件を書くFilterを場合は別の書き方が必要でした。 FilterExpressionListとFilterExpressionを使い、複数のFilterExpressionをAndもしくはOrで纒める事で、 複数条件を指定できました。

FilterExpression filter1 = FilterExpression.newBuilder()
    .setFilter(
        Filter.newBuilder()
            .setFieldName("pagePath")
            .setStringFilter(
                Filter.StringFilter.newBuilder()
                    .setMatchType(Filter.StringFilter.MatchType.PARTIAL_REGEXP)
                    .setValue(pathRegex)
             ).build()
     ).build();

FilterExpression filter2 = FilterExpression.newBuilder()
        .setFilter(
            Filter.newBuilder()
                .setFieldName("session")
                .setStringFilter(
                    Filter.StringFilter.newBuilder()
                        .setMatchType(Filter.StringFilter.MatchType.EXACT)
                        .setValue("session_p")
                 ).build()
     ).build();

FilterExpressionList expressionList = FilterExpressionList.newBuilder()
    .addExpressions(filter1)
    .addExpressions(filter2)
    .build();

FilterExpression filterExpression = FilterExpression.newBuilder()
    .setAndGroup(expressionList)
    .build();

setAndGroupだけでなく、setOrGroup等が用意されています。 これでどんな条件も指定できそうですね!

Amazon Route 53のDNSクエリの「追加料金不要」は、すべての料金が無料になるわけではない

こんにちは。 エキサイト株式会社の三浦です。

Amazon Route 53は、AWS上でドメインを簡単に扱うことが出来るサービスです。

使用にはもちろん料金が掛かります。

掛かる対象にはいくつか種類があるのですが、その中の一つであるDNSクエリには、このように記述があります。

aws.amazon.com

DNS クエリに対する Amazon Route 53 サービスからの応答には、料金が発生します。ただし、クエリの対象がエイリアス A レコードであり、Elastic Load Balancing インスタンス、CloudFront ディストリビューションAWS Elastic Beanstalk 環境、API GatewayVPC エンドポイント、または Amazon S3 ウェブサイトバケットマッピングされている場合は、追加料金は不要です。

特定の対象の場合であれば「追加料金は不要」とのことですが、このドキュメントには「基本料金」「追加料金」という種別で料金は載っていません。 では、どういうことなのでしょうか?

ざっくり以下の二通りが考えられます。

  • クエリの対象が特定のものであれば、DNSクエリ料金はすべて無料
  • DNSクエリ料金には実は「基本料金」と「追加料金」があり、クエリ対象が特定の物の場合、「基本料金」は掛かるが「追加料金」は無料

今回はこちらを検証してみました。

検証

以下が検証対象のホストゾーンです。

検証ホストゾーン

NS・SOAレコード以外にはALBへのAレコードしかなく、「追加料金は不要」の範疇に入っていることがわかります。

このAレコードのクエリに対する実際の料金は以下になります。

料金

DNS-Queries に料金が発生していることがわかります。

以上から、「追加料金は不要」とは、「DNSクエリ料金がすべて無料」なのではなく、「基本料金」的なものは掛かるが、それ以上の「追加料金」はかからない、という意味であることがわかりました。

最後に

結果から見ればドキュメント通り「追加料金は不要」というところに落ち着きましたが、ドキュメントには「基本料金」「追加料金」という種類で料金が載っているわけではないので、確認も兼ねて検証してみました。

なにかの役に立てば幸いです。

Azure/SQL ServerからAWS/PostgreSQLへの2年間の移行作業を振り返る

はじめに

エキサイト株式会社 バックエンドエンジニアの山縣(@zsp2088dev)です。 新卒入社から早いもので2年が経過しました。

最近、エキサイトブログの大規模なメンテナンスが完了し、無事にクラウドとデータベースの移行が終わりました。 クラウドはAzureからAWSへ、データベースはSQL ServerからPostgreSQLへと移行しました。

staff.exblog.jp

このプロジェクトでは、私を含む3名で取り組み、2年間の努力の末に移行作業を達成できました。 チーム全員が一丸となって取り組んだ結果、このような大きなプロジェクトを成功させることができたことを嬉しく思います。

本記事ではエキサイトブログの2年間の移行作業を振り返り、移行のためにチームで取り組んできたことについて紹介します。

エキサイトブログとは

エキサイトブログは2004年から提供しているサービスで、2023年現在で19年間稼働しています。 エキサイトブログは多くの機能を提供しており、特にブロガーが自分自身のブログのデザインやメニューなどを自由にカスタマイズできる魅力的なサービスです。

エキサイトブログのリビルド経緯

エキサイトブログは19年間運営されており、その間にデータベースには大量のデータが蓄積されました。 そして、アプリケーションコードは開発が進むにつれて徐々に複雑化していきました。 現在アプリケーションコードはGitでバージョン管理されていますが、2015年頃まではSVNで管理されていたため、移行前の差分を参照することができず、過去のコードの変更履歴を追跡することができません。

現在使用しているファイルの中には、最長で9年間、変更されていないファイルもあります。 このように長期間にわたってサービスの運営をしていると、データベースへの大量のデータの蓄積やアプリケーションコードの複雑化などが起こり、 メンテナンスや移行作業が困難となっていました。

アプリケーション、データベース、クラウドに着目すると、以下の課題がありました。

  • アプリケーション
    • アプリケーションコードがメンテされておらず、新規の機能開発が難しい
    • VMで運用しており、急激なトラフィック増加の対応が難しい
  • データベース
    • SQL Serverのライセンス費用が高額であり利益を圧迫している
    • SQL Serverの構成が非推奨であるプリンシバル/ミラー構成になっている
  • クラウド

SQL Serverのライセンス費用が高額である件については、フリーライセンスのデータベースであるPostgreSQLに移行することが望まれていました。 また、社内標準でないAzureを使用している件については、Azure固有の問題に対して知見を共有できず対応に時間がかかってしまうといった問題点がありました。

上記の理由から、今後もエキサイトブログを運営していくために、2021年3月にエキサイトブログをリビルドすることに決定しました。

リビルドするために取り組んだこと

Azure/SQL ServerからAWS/PostgreSQLに移行するにあたり、取り組まなければならないことが非常に多くありました。

  • データベースの処理はAPIサーバーで行う
  • ストアドプロシージャからの脱却
  • SQL ServerPostgreSQLで使用可能なクエリーにする
  • Azure VM → Azure Container Apps
  • Azure Container Apps → AWS ECS

それぞれの詳細について、以下でまとめていきます。

データベースの処理はAPIサーバーで行う

エキサイトブログにはトップ面、ブロガー面、ブロガー管理画面、管理者ツールなど数多くのアプリケーションがあります。 それらすべてのWebサーバーがデータベースの処理を行っていました。 データ移行時の困難さや今後の継続的な開発を考慮した結果、Webサーバーではフロントエンドの表示に集中させ、データベースの処理やビジネスロジックAPIサーバーで実装することに決めました。 この時、静的型付け言語であることや所属部署の開発責任者がJavaに精通していることを理由に、APIサーバーをJavaで構築することになりました。 さらに、データベースへの依存度が高いストアドプロシージャはJavaで再実装を行うことに決めまりました。

リビルド前とリビルド後の構成図を以下に示します。

ストアドプロシージャからの脱却については、次節で詳細にまとめます。

ストアドプロシージャからの脱却

ストアドプロシージャとは、データベースに保存される手続きです。 SQL ServerPostgreSQLで書き方が大きく異なるため、データベース移行をするのに一番の障壁となります。 前節のとおり、今後はストアドプロシージャを使用しない方針としたため、ストアドプロシージャの処理をJavaで再実装することにしました。

まずはSQL Serverに登録されているストアドプロシージャの数を数えるところからはじめました。 この時、約300個のストアドプロシージャがあることがわかりました。

使用していないストアドプロシージャをすべて移行するのは大変な作業です。 そこで、ストアドプロシージャを呼び出している箇所にログを仕込み、使用しているストアドプロシージャのみを移行することにしました。 調査の結果、約3割のストアドプロシージャを使用していることが判明したので、 これらのストアドプロシージャをJavaで再実装することにしました。 この時、再実装するストアドプロシージャをスプレッドシートで管理しながら1つ1つ実装していきました。

ストアドプロシージャはSQLクエリーの組み合わせであるため、再実装することはそこまで難しくないと考えていました。 実際にいくつかのストアドプロシージャは「ある値がAならレコードを追加するが、Bならレコードを更新する」といった簡単なものも多かったです。 しかし、SUBSTRING関数やCHARINDEX関数を使用した文字列の処理、WHILE文を使用した繰り返し処理など読み解くのが難しいクエリーも多く存在しました。

この作業は、他の作業と並行して行っていましたが、すべてのストアドプロシージャから脱却するまで半年がかかり、長期にわたる移行作業となりました。 そして、使用しているすべてのストアドプロシージャをJavaで再実装できた時には、社内カンファレンスで「アプリケーションをコンテナ化するまでの取り組み ─ストアド・プロシージャ脱却編─」というタイトルで発表しました。

tech.excite.co.jp

SQL ServerPostgreSQLで使用可能なクエリーにする

SQLServerからPostgreSQLにデータベースを移行する場合、SQLクエリーの書き方は基本的に同じですが、データベース固有の書き方や関数などによって、同じSQLクエリーが動作しないことがあります。 そのため、SQLServerPostgreSQLの文法や機能の違いに注意しながら、移行先のPostgreSQLに合わせてSQLクエリーを修正しなければなりませんでした。

以下に示す表は、実際に対応しなくてはならなかった一部のクエリーの一覧です。

SQL Server PostgreSQL 説明
TOP LIMIT 取得件数の指定
ISNULL COALESCE NULL値の時の対応
CONVERT TO_CHAR 文字列の変換
NEWID RANDOM ランダム値の生成
WITH (NOLOCK) ── トランザクション分離レベルの指定
SCOPE_IDENTITY RETURNING [id] 最後に更新されたID値の取得

取得件数を指定するTOPLIMITについては、どちらのデータベースでも使用できるOFFSETを使用することで対応することにしました。

一方で、どちらのデータベースでも使用可能なSQLクエリーにできない場合は、それぞれで動作するSQLクエリーを用意することにしました。

データベースとのやり取りにはMyBatisを使用しています。 MyBatisには、以下のようにdatabaseIdを指定することで接続先のデータベースに応じてSQLクエリーを切り替えることのできる機能があります。 これにより、例えばSQL Serverの場合はCONVERT関数を使用し、PostgreSQLの場合はTO_CHAR関数を使用することができるようになります。

@Select(value = """
        SELECT
            CONVERT(VARCHAR(7), date, 23) AS month
        FROM
            sample_data
        """, databaseId = "sqlserver" )
@Select(value = """
        SELECT
            TO_CHAR(date, 'YYYY-MM') AS month
        FROM
            sample_data
        """, databaseId = "postgres" )
List<Result> getSampleDataMonth();

databaseIdの詳細については、以下のドキュメントをご参照ください。

mybatis.org

Azure VM → Azure Container Apps

アプリケーションをコンテナ化して運用するにあたり、Azure VMからAWS ECSの移行は環境の差異が大きすぎるため、まずはAzureのコンテナサービスでコンテナを稼働させることにしました。

以下に示す図は、移行前のAzure VMの構成です。 1つのVMで複数のアプリケーションが稼働しています。 そのため、1つのアプリケーションの負荷が増大したときにスケールアウトするのが難しく、事前に大きなサイズのVMを用意する必要がありました。

Azureにはいくつかのコンテナサービスがあります。 その中でスケールアウトが容易にできることや、アイドル時間の料金が安いことを条件に選定した結果、Azure Container Appsを採用することに決めました。

スケールアウトが容易にできることについては、Azure Container Appsではリクエスト数やCPU使用率などを条件にスケールアウトの条件を簡単に設定することができます。 またアイドル時間の料金が安いことについては、開発や動作確認で使用するテストとステージング環境と非常に相性がよかったです。

次に示す図は移行後のAzure Container Appsの構成です。 Azure VMからAzure Container Appsに移行したことで、上記の問題点を解決することができました。 また、CI/CD環境の整備をしたりコンテナ環境のコード化をしたりと、AWS ECSに移行しやすいように整備することもできました。

Azure VMからAzure Container Appsの移行については、以下の記事で詳しく説明しています。

tech.excite.co.jp

また、移行して3ヶ月経過したときの使用感についても以下の記事にまとめています。

tech.excite.co.jp

Azure Container Apps → AWS ECS

Azureで安定したコンテナ稼働ができるようになった段階で、AWS ECSの構築を開始しました。 AWS ECSの構築については、社内に多くの知見があるため、他サービスのメンバーと協力しながら構築を進めました。

アプリケーションをコンテナ化していたため環境差異を抑えられ、素早くテスト環境を構築することができました。 実際に移行したものを下記にまとめます。

以下のとおり、全体的な構成を大きく変えることなくクラウド移行を実施することができました。

Azure AWS 説明
Azure Container Registry Amazon ECR コンテナレジストリ
Azure Container Apps Amazon ECS コンテナ実行環境
Azure Application Gateway Application Load Balancer L7ロードバランサー
Azure Cache for Redis Amazon ElastiCache for Redis キャッシュ
SQL Server 2016 Amazon Aurora PostgreSQL データベース

当日の様子

ここまでですべての準備が整いました。 最後の仕上げであるAzureからAWSの切り替えを行います。

クラウドの切り替え時には、ユーザー影響を最小限に留めるために、ブログ記事の閲覧のみを許可するような仕組みを導入することにしました。 具体的には、データの参照のみを許可し、データの追加/更新/削除を許可しないようにしました。

以下に示す図は、メンテナンス前のAzureの構成図です。 すべてのリクエストがAzureを向いています。

続いて、メンテナンス予定時刻になったら、ブログ記事の閲覧のみを許可するようにしました。 データベースの処理はAPIサーバーに集中しているため、APIサーバーでGETリクエストのみを受け付けるようにしてデータの参照のみをできるようにしました。 これにより、データの追加/更新/削除が行われなくなります。 また、Webサーバーではブロガー管理画面やコメントページ等をメンテナンスページにリダイレクトさせるといった処理を行いました。

続いて、データベースの参照のみを許可しているときに、Embulkを使用してSQLServerからPostgreSQLのデータ移行を行いました。 データ移行が終わり、SQL ServerPostgreSQLのデータの差分がなくなったことを確認しました。

データ移行後は、DNSの向き先を変更しました。 この時、AWS上でもメンテナンスモードを継続して行っています。 これは、不具合があった場合にすぐにAzureに戻せるようにするためです。

最後に、メンテナンスを解除してすべての機能が利用できるようにしました。

2年間の移行作業を終えての感想

まずは、2年間の移行作業が無事完了したことが何よりも嬉しいです!🎉

私は、新卒で入社して2週間後からエキサイトブログのリビルドに参画し、初期の段階から携わっていました。 当初はエキサイトブログに対するドメイン知識が十分でない状態から進みましたが、 リビルドを通してエキサイトブログのシステムを学んでいくことも多くありました。

最後に、一緒にプロジェクトに取り組んでくれたメンバーや、協力していただいた他サービスのメンバーの皆様、ありがとうございました。

おわりに

本記事では、Azure/SQL ServerからAWS/PostgreSQLへの2年間の移行作業についてまとめました。

APIサーバーから新APIサーバーへのリビルド、ストアドプロシージャからの脱却、DB互換のあるクエリーの対応およびクエリーチューニング、 Azure VMからAzure Container Appsの移行、AzureContainer AppsからAWS ECSの移行、AzureからAWSクラウド移行、 SQL ServerからPostgreSQLのデータ移行を実施しました。

今後は、AWSのサーバー構成/費用の最適化や新機能の開発等に着手していく予定です。 最後まで読んでいただき、ありがとうございました。

お知らせ

エキサイトブログの移行プロジェクトのメンバーである中尾が、JJUG CCC 2023 Spring に「Spring Boot × MyBatis × FreeMarker を使って、データベースの接続先を安全に変更します。」といったテーマで登壇します。 こちらのセッションでは、エキサイトブログの移行に関わるデータベースの処理周りについて話をする予定です。 ご興味ある方は、ぜひご参加ください!

eventregist.com

採用アナウンス

エキサイトではフロントエンジニア、バックエンドエンジニア、アプリエンジニアを随時募集しています。 また、長期インターンも歓迎しています。

カジュアル面談からもOKです。少しでもご興味がございましたら、お気軽にご連絡頂ければ幸いです。

▼ 募集職種一覧 ▼ recruit.jobcan.jp

Redisのスローログを確認する方法とKEYSではなくSCANを使った方がいい理由

こんにちはしばたにえんです 今回は、Redisのスローログを確認する方法とKEYSではなくSCANを使った方がいい理由について紹介します。

スローログを確認する手順

1. redis-cliでredisに接続する

redis-cli -h sample-redis-host

2. スローログを取得するクエリを実行する

 slowlog get 128

128 は取得するスローログの件数

1) 1) (integer) 6
   2) (integer) 1683509330
   3) (integer) 17108
   4) 1) "GET"
        2) "0"
        4) "sampleKey"
...

こんな感じで表示されます。

見方としては

1) 自動採番されたID
2) クエリ実行時のUnixタイムスタンプ
3) クエリ実行にかかった時間(マイクロ秒)
4) クエリとそのパラメータ

となってます。

Redisの注意点

Redisはシングルスレッドで動いているため、スロークエリがあった場合に他のリクエストも全てスロークエリの実行が終わるまでレスポンスが返せなくなってしまいます。
なので、当前ですが、クエリを実行する場合は細心の注意を

KEYSは使わない方がいい

データが入っているか確認したいときにKEYSを実行してデータを取得したいなんてことがありますが、おすすめできません。
KEYSは指定の条件のキーを全て見に行きます。この処理が終わるまで他の処理は待たないといけません。
代わりにSCANを使いましょう。

SCANの概要

SCANはカーソルベースで繰り返し実行することでキーを取得します。-- COUNTオプションの件数分(指定しなければ10件まで)データを取得し終えると処理を止めてキーと次のカーソルを返してくれます。
次のカーソルが0になったとき全てのキーの検出の完了となります。

SCANを実行する

SCAN 0

# プレフィックス指定で取得したい場合
SCAN 0 MATCH "sample*"

0番目の要素から取得します。

SCAN 0 MATCH "sample*"
1) "53"
2) 1) "sample:da1f1052aef0582e8f5472df91a74c0f"
   2) "sample:ae6d55e35b80643d45fe0351de523068"
   3) "sample:db93635af5fdc8366f666cc55d18cc3f"

こんな感じで表示されます。

見方としては

1) 次の呼び出しで使う新しいカーソル
2) キーの配列

となります。

次の呼び出しで使う新しいカーソルが0になるまで実行しましょう

SCAN 53 MATCH "sample*"
1) "0"
2) 1) "sample:ba18d60898fecb5c8fa68c425f61b45a"

これでsampleに前方一致するキーは

sample:da1f1052aef0582e8f5472df91a74c0f
sample:ae6d55e35b80643d45fe0351de523068
sample:db93635af5fdc8366f666cc55d18cc3f
sample:ba18d60898fecb5c8fa68c425f61b45a

の4つということがわかります。

参考

mogile.web.fc2.com mogile.web.fc2.com

MyBatis Generatorで出力されるMapper&Modelにsuffixをつけてみる

お久しぶりです。中尾です。

長いことブログを書いていませんでしたが、少しずつ書こうと思います。

今回は MyBatis Generatorで出力されるドメインオブジェクト名にsuffixをつけてみます。

MyBatis GeneratorはTableからドメインオブジェクトを作成する機能なのですが、ドメインオブジェクト名はデフォルトでTable名に設定されます。

変更するにはdomainObjectRenamingRuleを設定します。

<domainObjectRenamingRule searchString="^(.*)$" replaceString="$0Dto" />

mybatis.org

domainObjectRenamingRuleは内部では java.util.regex.Matcher.replaceAll を使っているため、正規表現でTable名を先頭から末尾までを取得し、suffixに文字列を結合します。 上記の例だと以下のようになります。

Table名 ドメインオブジェクト名 domainObjectRenamingRuleを使用
test_user TestUser.java TestUserDto.java

このように、Dtoが末尾に付けられました。

suffixでもprefixでもどちらも付けられます。

ドメインオブジェクト名を変更したいと考えている人は参考にしていただけると幸いです。

Alpine Linuxで、URLに対するヘルスチェックを行う方法

こんにちは。 エキサイト株式会社の三浦です。

コンテナでの開発をする際、できるだけサイズを減らすため、Alpine Linux(以降Alpine)を使っている方も多いと思います。

ですがAlpineは、サイズを減らすためにデフォルトでは curl コマンドが入っていません。 そのため、例えばAPIのヘルスチェックのための curl を使ったエンドポイントへのアクセスは、少なくともデフォルトそのままでは出来ないということになります。

今回は、 Alpineを使っている場合の、APIのエンドポイントへのヘルスチェックのためのアクセス方法を説明します。

方法1:curlをインストールする

1つめの方法は、シンプルに curl をインストールするというものです。

FROM ***-alpine

RUN apk update \
    && apk add --upgrade curl

こうすれば、あとは通常通り curl でヘルスチェック可能です。

curl -f http://localhost/health/path || exit 1

方法2:wgetを使用する

2つめは、 wget を使用する方法です。

Alpineは、 curl はデフォルトでは入っていませんが wget は入っているので、それを活用します。

wget --quiet --spider http://localhost/health/path || exit 1

-q -O オプションを使う方法もあるようです。

特に curl を他で使う必要性が無いのであれば、デフォルトで入っているこの wget を使った方法の方が、手間がかからず良いのではないでしょうか。

最後に

Alpineなど、コンテナサイズが小さくなるように作ってあるものは、その性質上普段使いしているコマンドが無いこともあります。

今回のように代替案を見つけたり、どうしようもない場合は自分でインストールしましょう。

「伝わるFigma」を考えてみた

こんにちは。内定者アルバイトデザイナーの齋藤です。

今回は、UIデザインをさせていただく中で気がついた、デザイナーからエンジニアさんに「伝わるFigma」にするにはどうしたらよいのか、自分なりに考えてみましたので自戒も含めて共有します。

超超新米ですので間違っていることを言っていたらご指摘ください🙇

構造と仕組みがわかること

例えばボタンのデザインを検討してたとしましょう。そこで以下の2つのようなものをFigmaで作成したとします。

見た目としてはどちらも全く一緒なのですが、実は左側がオートレイアウト使用していないもので右側は使用しているものになります。

「見た目としては同じだからいいんじゃね?」と思うかもしれませんが、使用していない場合色々と弊害が生じる恐れがあります。

もう少し、詳しく見てみましょう。

オートレイアウトなしのボタン
こちらは、オートレイアウトなしのボタンのインスペクトです。

widthheightの値を見るとわかる通り、固定値で設定されています。これだと、大きさが固定のボタンを必要としていると誤解されてしまうかもしれません。

また、ボタン内のアイコンとテキストの位置もAbsoluteになってしまいます。よって、汎用的でない使い勝手の悪いボタンが出来上がることになります。

次に、オートレイアウトありの場合を見てみましょう。

オートレイアウトありのボタン
ご覧の通り、オートレイアウトを使用することで構造と仕組みが分かりやすくなりました。

今回の場合では「display: flex;でボタン内要素を横並びにして、align-items: center;で要素を上下中央付近に揃えて、gap: 4px;だから要素と要素の間隔は4pxになって....」と言った具合に、デザインからコードへの脳内変換が容易くなります。

また、paddingが設定されていることから、テキストの分量によって大きさが変化する仕様であることが分かり、汎用性のあるボタンが出来上がりました。

このように、オートレイアウトの有無で「構造と仕組み」が伝わりやすくなります。

状態の変化パターンが見えること

ボタンを筆頭に、Hoverをはじめとした状態(見た目)の変化が生じる場合が多くあります。 そのため、どのような変化パターンがあるのかもFigma内で定義した方が良いと考えます。

その際に有用なのが、バリアンツ(Variants)です。 バリアンツとは、「同要素・別状態」のコンポーネントをグループ化して、コンポーネントを使用した際に簡単に変化させることができる機能です。(ex. ボタンのHover時と通常時の切り替え)

例えば以下のように、通常時とHover時で背景色を変化するボタンを作成する場合を考えてみます。

この際、通常時のボタンのコンポーネントを作成すると同時に、Hover時のコンポーネントも作成してバリアンツとしてグループ化を行います。 プロパティとして「Hover」を作成して、通常時のものを「False」、Hover時のものを「True」にします。

バリアンツのプロパティ

この設定をすると、当該ボタンコンポーネントが使用されている箇所をフォーカスした際に、HoverのON/OFFスイッチが出現し、変化させることができます。

HoverのONとOFF

こうすることで、一目で状態の変化パターンを把握することができます。

再利用性と再現性があること

最後に、カラーやタイポグラフィーなどデザイン内で共通するものの扱いについてです。

以下のように同じボタンが複数箇所あったとしましょう。

同じボタンが複数箇所にある
AとBのボタンは見た目も構成要素も全く同じなのですが、カラーを見てみると・・・

AとBのFillの値

カラーコードが異なっているため、同じ色に見えても若干色が変わっているのです。こうなると、全く同じものなのに意図せずカラーが異なっていると判断されて、再利用できずに同じようなものを2回作ることになってしまいます。

パーツを作るごとに色を手打ちで、特にカラーピッカーで指定しているとこのような事態が起こりやすくなります。(※今回のようなボタンの場合は大抵コンポーネント化するため、毎回毎回色指定をすることは考えられにくいですが・・・)

このような事態を避けるために、スタイル(Styles)を使用することがおすすめです。 この機能は、色やテキスト、シャドウなどのエフェクトまで、同じデザイン内で再利用される共通要素を定義することができるものです。

カラースタイルの作成

スタイルを設定することで、同じ色を使用したい場合にワンクリックで適用することができます。

スタイルで定義した色を適用

こうすることで、同じ色だけど若干カラーコードが異なるという事態を避け、エンジニアさんが:rootなどで共通要素を再現しやすくすることにもなると考えます。

まとめ

今回は、チームで開発する際にデザイナーからエンジニアさんに「伝わるFigma」とは何かを考察し特に重要であろう、

  • 構造と仕組みがわかること
  • 状態の変化パターンが見えること
  • 再利用性と再現性があること

を自分なりに考えてみました。他にも大切なことはたくさんあると思います。

デザイナーとエンジニアさんの齟齬を無くし、円滑に物事を進める一助となれば幸いです。 「もっと良い方法あるよ」や「これは違うのでは」というご指摘も大歓迎ですので、是非教えてください!!

ご精読ありがとうございました。

PHPとJavaでmd5の値を同じにする方法

エキサイトのエンジニア岩藤です。

PHPJavamd5の値を同じにする方法を紹介します。

sample.php

<?php
$md5 = md5("エキサイト");
echo $md5 . "\n";
PHP sample.php
2d56c309fdea63868f7397ebceba1867

Sample.java(サンプルはSpring Shellです)

@ShellMethod(value = "文字をmd5で暗号化する md5 text", key = "md5" , group = "md5")
public String md5(String text) {
    return DigestUtils.md5Hex(text);
}
shell:>md5 エキサイト
2d56c309fdea63868f7397ebceba1867

ちなみに方法論として上記にいきつく前に、最初は下記の方法でJava側は変換してたんですが、うまくいかず。 色々頑張ってしまったんですが、普通にシンプルにできたんですね、という事で今回メモとして残します。

最初うまくいかなかった書き方

try {
    String key = this.articleCode + "-" + this.articleId;
    byte[] md5Bytes = MessageDigest.getInstance("MD5").digest(key.getBytes(StandardCharsets.UTF_8)); 
    return new BigInteger(1, md5Bytes).toString(16);
} catch (NoSuchAlgorithmException e) {
    throw new RuntimeException("get token error");
}

上記だと先頭0の場合に、Java側で先頭0が削られてしまい一致せずではまりました。

PHP側 032e3f03122aa5162dd4a5351539b3ea
Java側 32e3f03122aa5162dd4a5351539b3ea