Spring Bootでコンポーネント名が重複してエラーになったときの解決策

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 Spring Bootでコンポーネント名が重複したときに、ConflictingBeanDefinitionExceptionが発生してエラーとなってしまいました。 その原因と解決策についてまとめました。

別のパッケージの同一のコンポーネント

例としてItemに関するItemServiceについて考えてみます。 ItemServiceはWebとアプリで処理が少し異なるため、それぞれで実装します。

  • WebのService
package com.sample.service.item;

import org.springframework.stereotype.Service;

@Service
public class ItemServiceImpl implements ItemService {
    @Override
    public Item getItemByID(Long id) {
      /* 処理 */
    }
}
  • アプリのService
package com.sample.app.service.item;

import org.springframework.stereotype.Service;

@Service
public class ItemServiceImpl implements ItemService {
    @Override
    public Item getItemByID(Long id) {
      /* アプリ固有の処理 */
    }
}

上記2つは似ていますが、パッケージが異なることに注意が必要です。 通常、同一のクラス名であったとしても、パッケージが異なれば問題なく利用できます。 しかし、ConflictingBeanDefinitionExceptionが発生しコンパイルできずにエラーとなってしまいました。

ConflictingBeanDefinitionException: 
Annotation-specified bean name 'itemServiceImpl' for bean class [com.sample.app.service.item.ItemServiceImpl] conflicts with existing,
non-compatible bean definition of same name and class [com.sample.service.item.ItemServiceImpl

原因

コンポーネントの場合、同一のクラス名が2つ以上存在するときにコンパイルできずにエラーとなってしまいます。 これは、Spring BootがDIするときに、どちらのBean名もItemServiceImplとなってしまうからです。 そのため、どちらのSpring BootがどちらのItemServiceImplをDIしてよいのか判別できなかったため、ConflictingBeanDefinitionExceptionが発生してしまいました。

解決策1:コンポーネントプレフィックスに文字列を付与する

一番単純でわかりやすいのはクラス名の前にAppAdminなどの文字列を付与することです。 これで問題なくコンパイルおよび実行することができます。

package com.sample.app.service.item;

import org.springframework.stereotype.Service;

@Service
public class AppItemServiceImpl implements AppItemService {
    @Override
    public void getItemByID(Long id) {
      /* アプリ固有の処理 */
    }
}

解決策2:アノテーションの引数に文字列を指定する

@Controller@Service@Repository@Componentなどのアノテーションの引数に独自のBean名を定義します。 これにより、同じクラス名のコンポーネントが複数存在しても問題なく実行することができるようになります。

package com.sample.service.item;

import org.springframework.stereotype.Service;

@Service("AppItemServiceImpl")
public class ItemServiceImpl implements ItemService {
    @Override
    public void getItemByID(Long id) {
      /* 処理 */
    }
}

このとき、アノテーションの引数には変数を渡すことができます。 これで管理するのもよいかもしれません(ただし面倒だとは思います)。

@Service(ComponentName.APP_SERVICE_IMPL)

おわりに

Spring Bootでコンポーネント名が重複してエラーになったときの原因と2つの解決策についてまとめました。 個人的には、命名規則を決めて、管理者用ならAdminを、アプリ用ならAppプレフィックスに付与したほうがよいのかなと考えています。 例えば、解決策2で運用したときにItemServiceIDEでファイル検索したときに、ファイルパスまで含めて見ないとたどり着けないため、少し不便に感じるからです。 ただ、どちらの実装方法も大きく異なることはないため、最終的には好みになりそうです。

どこまでアプリケーションを「完璧」にすべきか

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

アプリケーションを作るときや改修するとき、すべてのエンジニアが「完璧なアプリケーションを作ろう」と一度は思うはずです。

しかし、シンプルなコードで済むアプリケーションならともかく、複雑になればなるほど「完璧」を達成するのは難しくなっていきます。

今回は、どこまで「完璧」を目指すべきか、持論を語っていきたいと思います。 なおこちら、あくまで持論であり、必ずしも会社の方針と100%一致するとは限らないので、あらかじめご了承下さい。

完璧なアプリケーションとは

まず最初に、「完璧なアプリケーション」とは何でしょうか? それはもちろん、「いついかなる時も問題が起きない」アプリケーションです。

例えば、「2つの値を渡すとその合計値を返す」というアプリケーションの場合、正の数・負の数・非整数でも計算が可能であるとか、(仕様次第ですが)IntegerやLong値の限界を超えても問題なく動作する、などが「完璧」の条件として挙げられます。

この程度であれば問題ありませんが、ではWebサイトであればどうでしょうか? 考えられる条件としては、

  1. サイトにアクセスすれば、必ず想定通りの情報がサイトに載っている
  2. いつサイトにアクセスしても、一定以下の時間でページが表示される

でしょうか。 一見条件も少なく、簡単に達成できるような気もしてきますが、Webサイトというのは様々な状況下に陥ることが多々あります。 例えば、

  • 急に大量のアクセスが来る
  • 外部からの攻撃を受ける
  • ごく限られた条件下でしか起きない、コード内の潜在的なバグが発火する
  • 運用上のミス等で、DBなどのデータソース内のデータに不整合が生じる
  • インフラ周りに物理的な障害が発生する
  • クラウドを使用している場合、クラウド側に問題が発生する

などが挙げられるでしょう。 これら(もちろんここで挙げていないその他様々な原因も)をすべて解決し、「完璧なアプリケーション」を作り上げることは可能なのでしょうか?

「完璧」でなく「ベストエフォート」なアプリケーションを作る

個人的には、これら全てを完全に解決することはかなり難しいと考えています。

もちろん、例えば「急に大量のアクセスが来る」の対処法として「サーバ/コンテナが自動スケールするようにする」ですとか、「外部からの攻撃を受ける」の対処法として「セキュリティ対策を万全にする」など様々な解決策はありますが、それでも自動スケールが完了するまでの数分間はページ速度の急激な低下、もしくはタイムアウトはどうしても発生してしまうでしょうし、外部からの攻撃も新しい種類のものが出てきてしまえばまた新しく対処法を考える必要が出てきます。

さらに言えば、例えばごく限られた条件でしか起きない問題に対してどこまで対応するべきでしょうか? 1年に1~2回、しかも1回あたり数秒程度しか起きない問題に対して、何日も何週間もかけて原因調査・対応をするべきなのでしょうか?

私もしばらくこういった問題に頭を悩ませてきましたが、他のエンジニアと相談したり、色々と考えた結果、「完璧」ではなく「ベストエフォート」こそ目指すべきアプリケーションなのではないか、と考えるようになりました。

アプリケーション、及びそれらの上で展開されるサービスには、もちろん譲れない点があります。 例えば、「1日6時間はエラーでサイトが見られない」などといったサービスは、いくらなんでも受け入れられないでしょう。 可能な限り、いつでも見られるようにする必要はあります。

しかし、例えば「年間で99.9%の時間のサイトの動作は保証する(0.1%の時間はエラーでサイトが見られない可能性がある)」だとしたらどうでしょう? この程度であれば許容範囲、というサービスならあるのではないでしょうか? なおこれは、1年 = 365日 = 8760時間の0.1%なので、8.7時間ほどエラーが許容される、ということになります。

考えてみると、そもそもインターネットという仕組みそのものがベストエフォートという考え方で作られています。 今回の例であるWebサイトでいえば、その上で作られているアプリケーションがベストエフォートを目指すのは、ある意味自然なのかもしれません。

また有名なAWSも、サービスレベルアグリーメントという形で、「完璧」ではなく「ベストエフォート」でサービスを作っていることを宣言しています。

もちろん、サービスの内容によってどこまで許容されるかは変わってきます。 ニュースメディアサイトと医療用のオンライン手術サービスのようなものであれば、許容されるエラーのレベルは全く違うことでしょう。

ただしなんにせよ、やみくもに「完璧」なアプリケーションを作ることを考えるのではなく、まずはそのサービスでどこまでエラーが許容されるかを考えるべき、というのはすべてのアプリケーションに共通しているのではないでしょうか。 そして、許容されるエラーレベルに対して、適切に「ベストエフォート」なアプリケーションを作っていくことが、開発速度と開発精度の両方を兼ね備えた開発方法なのかもしれません。

最後に

AWSのように非常に大きく、インフラそのものをサービスとして提供するサービスであれば、最初から「~~%までの正常動作は保証する」というように宣言をしています。 一方で、いわゆる一般的なサービス・アプリケーションであれば、暗黙的に「まれに障害が発生することもある」という認識はありつつも、どこまでエラーが許容されるかという話し合いをすることは少ないのではないでしょうか? 特に自社開発であれば、その傾向は大きなものとなるでしょう。

ですがエラーや障害というのは、そのサービスが大きくなればなるほどいつか必ず起きるものとなっていきます。 例え自社開発のサービスであっても、一度はエラー許容レベルに関して話し合って決めることで、開発速度と精度のバランスを取ることができ、長期的に見てサービスの開発に良い影響を与えるのではないでしょうか。

はじめてのFlutter

iXIT株式会社の堀です。

新しい事をはじめたいと思い、グループ会社でも採用が決まったFlutterの勉強をはじめました。
2018年末にGoogleからFlutterがリリースされ、2年以上も経つのでネット上にも情報がたくさん載っています。 同じようにこれから始める人の参考になればと思い記事書かせて頂きます。(技術的要素はほぼありません)

何本か入門記事を読んだあと、環境構築。
Android StudioXcodeもすでにインストール済みだったので、あっという間に環境が出来上がりました。
最近のフレームワークは手とり足取り感満載ですね!
その後は、毎度おなじみ「Hello World」かと思いきや、用意されているサンプルアプリはカウンターアプリでした。
ソース内にコメントもいっぱい!(英語だけど)

カウンターアプリ
カウンターアプリ

Dartの入門書を見ながら、軽くソースを解析。 これだけのステップ数でスタイルもまとめて出来ちゃうなんてFlutterすごいかも。 (ただし、規定外のデザインにする時大変そうな気が・・・)

次の1歩として新しいアプリを作ろうと思い、見つけたのがこちら。

eh-career.com

解説も細かく書かれており、入門アプリにはちょうど良さそう!
早速、記事を見ながらプログラミング・・・
あれ?記事通りに書いてるのにエラーが・・・
スペルミスかなっと思いコピペ。それでもエラーが解消されないので、エラーメッセージで検索。 いっぱい出てきました。「その書き方もう使えないよ〜」ってコメントでした。 最近よく聞く「破壊的変更」ってやつですね。
下位互換はせず、新しく作り直してシンプルにする!って方針は賛成ですが、古い記事に惑わされて情報の海で溺れないように要注意です。

上記の解説ページとバージョン違いで出たエラーを潰しながら、どうにか完成。
Githubリポジトリ見てもつまらないので、Excite Tech Blogを表示できるように改良。

記事一覧アプリ
記事一覧アプリ

もうちょっとステップアップしてFirebaseと絡めたアプリを作りたいなと思っていたところ、程よいサンプルアプリ見つけました!

github.com

Github上にソースだけでなくFlowchartや利用するAPIリンクなどもキレイに纏められています。
Youtube3本立てのTutorialもあるのでこちらを見ながら実装。
(解説は英語ですが聞き取りやすい話し方ですし、コード見ながらなので分かりやすいです)
バージョン違いによるエラーを解消しつつ、Tutorial#2まで完了。

投票アプリ
投票アプリ

引き続き、完成を目指して進めて行きたいと思います。

YoutubeにはTutorialがたくさん載っています。
英語だからと諦めてしまうには勿体ないくらい素敵な動画も多いので、みなさんもぜひ挑戦してみてください!

エキサイトは「iOSDC Japan 2021」に協賛します!

今週金曜日から開催される「iOSDC Japan 2021」に、エキサイトはシルバースポンサー、Tシャツスポンサーとして協賛します。 イベント概要については、以下をご覧ください。

iOSDC Japan 2021

開催日時 : 2021年9月17日(金)〜 9月19日(日)

開催場所 : オンライン開催

公式サイト :

iosdc.jp

公式Twitter :

twitter.com

最後に

エキサイトでは一緒に働いてくれる仲間を絶賛募集しております!

また、長期インターンも歓迎していますので、ご連絡お待ちしております!!🙇‍♀️

www.wantedly.com

そして、iOSDCトークンはこちらになります!

#エキサイト

それでは皆さま、 iOSDC Japan 2021を一緒に楽しんでいきましょう!

DefaultErrorAttributesについて

エキサイト株式会社の中尾です。

RestControllerAdviceで拾えない例外処理を拾う方法を記載します。

よくある方法は、ExceptionHandlerで全てcatchする方法だと思います。

    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleNotFoundException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return "all Exception catch";
    }

しかし、これだとResponseStatusが全てINTERNAL_SERVER_ERRORになります。 ResponseStatusを指定しない場合、ステータスOKになってしまいます。

なぜこのようなことしないといけないのでしょうか?

そもそもデフォルトのエラーレスポンスはいかになっています。

{
  "timestamp": "2021-09-04T20:25:31.581+09:00",
  "status": 404,
  "error": "Not Found",
  "trace": "org.springframework.web.servlet.NoHandlerFoundException: No handler found for GET /aaa\n\tat org.springframework.web.servlet.DispatcherServlet.noHandlerFound(DispatcherServlet.java:1278)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1041)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:626)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:733)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.servlet.resource.ResourceUrlEncodingFilter.doFilter(ResourceUrlEncodingFilter.java:67)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:94)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.session.web.http.SessionRepositoryFilter.doFilterInternal(SessionRepositoryFilter.java:142)\n\tat org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:82)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:374)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1707)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:834)\n",
  "message": "No handler found for GET /aaa",
  "path": "/aaa"
}

エラー情報ですぎですよね。 本番環境でこれだと困ると思います。 以下の設定を追加することで、traceログを非表示にできます。

server.error.include-message: never
server.error.include-binding-errors: never
server.error.include-stacktrace: never
server.error.include-exception: false

traceとmessageが消えました。

shogo.nakao@localhost: $ curl http://localhost:8080/aaa | jq                                                                                                    [/Users/shogo.nakao]
{
  "timestamp": "2021-09-04T20:34:27.516+09:00",
  "status": 404,
  "error": "Not Found",
  "path": "/aaa"
}

しかし、アプリケーションによって、エラーレスポンスの形は変わると思います。 アプリケーションに合わせてエラーレスポンスを変えてあげましょう。

@Component
@Profile("!local")
public class CustomErrorAttributes extends DefaultErrorAttributes {

    /**
     * error response default setting 
     *
     * @param webRequest
     * @param options
     * @return
     */
    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        final String message = this.getMessage(webRequest, this.getError(webRequest));
        Map<String, Object> customErrorAttribute = new HashMap<>();
        customErrorAttribute.put("error",
                new ErrorResponse.ErrorData()
                        .setMessage(message));
        return customErrorAttribute;
    }
}
{
  "error": {
    "message": 404
   }
}

解説します。

DefaultErrorAttributesを継承することで、デフォルトエラーレスポンスの型を変えられます。 @Profile("!local")を設定することでローカル環境以外の場合に適応させます。 ローカル環境では詳細なトレースログみたいですからね。 どっちにしてもコンソールログに詳細なログは出力されますが。

final String message = this.getMessage(webRequest, this.getError(webRequest));

こちらで、this.getMessageとthis.getErrorはDefaultErrorAttributesの実装を見てください。

RestControllerAdviceについて

エキサイト株式会社の中尾です。

SpringBootでexceptionが発生したとき、特定のエラーレスポンスで返す方法を記載します。

以下、コードになります。

@RestControllerAdvice
@Slf4j
public class ExceptionController {
    @ExceptionHandler({BadRequestException.class})
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String handleBindException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return exception.getMessage();
    }

    @ExceptionHandler({NoHandlerFoundException.class})
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handleNotFoundException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return "404 Not Found";
    }

    private void writeLog(StackTraceElement[] stackTraceElements){
        Arrays.stream(stackTraceElements)
                .limit(10L)
                .forEach(e -> log.error(e.toString()));
    }
}
public class BadRequestException extends RuntimeException {
    public BadRequestException(String s) {
        super(s);
    }
}
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  resources:
    add-mappings: false

解説します。

RestControllerAdviceはRestControllerと違ってGetMapping/PostMappingとかは記載せず、ExceptionHandlerで該当のExceptionが発生したときResponseStatusを返します、

spring.pleiades.io

動作検証

shogo.nakao@localhost: $ curl http://localhost:8080                                                                                     [/Users/shogo.nakao]
XXXX must not be empty

404の場合、springの設定ファイルにthrow-exception-if-no-handler-found: trueとadd-mappings: falseをプロパティファイルに設定した上でRestControllerAdviceに追加してください。

shogo.nakao@localhost: $ curl http://localhost:8080/aaa                                                                                                         [/Users/shogo.nakao]
404 Not Found

うまくいきました。

なお、以下のようにExceptionHandlerになにも指定しない場合、全てのエラーをキャッチすることができます。

    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleNotFoundException(Exception exception) {
        this.writeLog(exception.getStackTrace());
        return "all Exception catch";
    }

RestControllerAdviceには関係ありませんが、writeLogにstackTraceのエラーを10行ぐらいまで出力する設定になります。

        Arrays.stream(stackTraceElements)
                .limit(10L)
                .forEach(e -> log.error(e.toString()));

GitHub上でVisual Studio Codeを起動してコードレビューをする

はじめに

エキサイト株式会社 21卒 バックエンドエンジニアの山縣です。 少し前にGitHub上でVisual Studio Codeを起動してコードレビューをすることができるようになりました。 実際に試してみたので共有します!

Visual Studio Codeを起動する

プルリクエストのページから「.(ドット)」を押すことで、ブラウザ上でVisual Studio Codeを起動することができます。

f:id:excite-kazuki:20210824095702p:plain

下記画像のページが出てきて少し待ちます。

f:id:excite-kazuki:20210905222910p:plain

その後、エディタが開きました!

f:id:excite-kazuki:20210905222945p:plain

プルリクエストを確認する

左のタブ一覧から「GitHub Pull Request」をクリックします。

f:id:excite-kazuki:20210905224229p:plain

ファイルとファイルの差分を見ることができます。 f:id:excite-kazuki:20210905224354p:plain

実際にGitHub上で見る「File Changed」と同じことが確認できます。

f:id:excite-kazuki:20210905224516p:plain

URLを見ると、トップレベルドメインGitHub.comVisual Studio Code.devになっていることが確認できます。

コードレビューをする

行番号の横のプラスボタンをクリックするとコメントをすることができます。 ただし、複数行選択してコメントができないことや、斜体や太字、箇条書きなどのボタンが用意されていないため、 完全上位互換にはならないことに注意が必要です。

f:id:excite-kazuki:20210905231217p:plain

実際にプルリクエストのページを見ると、コメントされていることが確認できます。

f:id:excite-kazuki:20210905231227p:plain

ファイル単位で差分を確認できるため、GitHub上でのみコードレビューをしている人にとっては、新たな選択肢が増えたのかなと思います。

ローカル環境でコードレビューをする方法

ローカルのVisual Studio Codeであれば拡張機能GitHub Pull Requests and Issues」を導入することでプルリクエストの作成や、コードレビュー、Issueの作成をすることができます。

f:id:excite-kazuki:20210824092038p:plain

また、JetBrains IDEであればPull Requestsタブから同様のことができます。

f:id:excite-kazuki:20210824092354p:plain

おわりに

GitHub上でVisual Studio Codeを起動してコードレビューする方法についてかんたんにまとめてみました。 コードレビューの選択肢が増えたのはとても嬉しいです!

監視とアラートとTwilio

概要

エキサイトの川崎です。

弊社の一部のサービスには通話機能があり、 その通話機能(以降 call systemと呼びます)をTwilioを使って作っています。

call systemを作成運用する上で、困ったこと/悩んだこと はいろいろあるのですが、 今回はcall systemのアラートについて書きます。

call systemをリリースしてからアラートにはたくさん助けられてありがたみも感じていますが、 どんな時にどんなものをアラート対象とすべきで、どのように通知すべきなのか今も迷走しています。 (本記事における「アラート対象」というのは、発生した際にメールやslackなど何らかの形で関係者に通知を送る事象のことです)

「やばそうなもの」はどこかに通知する or 出来るだけ自動化して対処すればいい程度の認識でいましたが、 唐突に、監視について教科書的な知識がない(っていうか監視の定義って何?アラートってそもそも何?な状態)のはまずいな、と感じて下記の本を読み始めました。 www.oreilly.co.jp

主にアラートメインの話が書いてある3章を読んでみた上で、学んだことや、その上でどんな対応したかを できる限り自分の言葉にして書いていきます。

当たり前だろな内容の予感もしますが、 あー確かにねと認識をお手伝いできたり、新しい視点や違う視点を提供できたらいいなと思います。

監視とは

あるものについて、その振る舞いや出力を確認し続けること。

本を読むまでは「悪いものを見つけること」だと思っていました。 「悪いものを見つけること」でも間違ってないとは思うのですが、 個人的にはこんな感じで捉えることでなぜかしっくり来ました。(字面のまんまじゃん)

監視している中で「やばい、動いてない」とはどういう状態か

ユーザがサービスを使った時に困る状態。

たまに、「問題がある」とか「やばい、動いてない」の定義が分からなくなることがあります。もしかして私だけでしょうか( ;∀;) そんな時上記の言葉を読むと頭を整理できて、 本当に問題があるのかを判定できるようになります。 これだけが定義じゃないと思うのですが、サービスはユーザが使うものなのでまずはこれ中心に考えることにしました。

例えば、「ユーザに電話をかけた」という出来事の記録に失敗することはぱっと見良くなさそうで、誰かに言いたくなりますが、 ユーザにとっては通話ができれば問題ないかもしれません。 (アラート対象とはせずにログに残す程度でいい)

アラートとは

監視してて、何かあった時に誰かに警告してくれる(いまだに不明瞭で草生えない)

アラート対象の種類

1. 緊急性が高くすぐに対処すべき事象

2. 確認はすべきだけど、緊急性が低くすぐに対処しなくてもいい事象

本で上記のような2種類が書かれていました。 この2つ+「何かあったら確認したいけど誰かに通知するほどでもない事象」の3つを明確に分けられていなかった私は、 このいずれかに意識的に分類してみることで、頭の中で整理がしやすくなりました。

例えば、通話できることで成り立っているサービスにおいて、 「通話ができない状態」は、大事故なのでアラート対象(すぐに関係者に通知すべき)、というのはすぐにわかりますが、 「原因不明の通話作成の失敗1回(もちろんユーザには適切な画面表示をしています)」はどうでしょうか。なんらかの問題に気がつけるきっかけなので、詳細確認はしたいですが、サービス全体において致命的ではありません、何これアラート対象?どうすべきなの?っていうかアラートってなんだっけ?と、こういうパターンでは毎回頭の中がごちゃごちゃになっていました。

「原因不明の通話作成の失敗1回」は「2. 緊急性は低い事象」と認識し、原因はよく調べたところいくつかのパターンを含んでいたので、一部のパターンはシステム改修で対応し、どうしようもないものは、ログに残すだけにして、関係者への通知は行わないようにしました。(場合によっては緊急性が低い用の通知場所に通知してもいいと思います)

もはや気持ちの問題ですね。その時に応じて頭を使うのは変わらないのですが、アラート対象の分類のしかたを1つ知っておくだけで頭の中の整理のしやすさがかなり変わりました。

アラートの精査

迷うならとりあえず入れておいて、現状をみて減らしていくのもアリ

(本に書いてあったかは謎。。。)

実際にやる前はそんな雑なことしていいかな、定期的に担当者(自分)Twilioのデバッガー見にいけばいいのでは?とモヤモヤソワソワしていて、 実際にやってみて「アリ!」って思えたので書いておきます。

call systemを使用しているサービスのことを考えると、call systemが安定した稼働をすることはかなり重要だったので、 何か問題があった時にすぐ気がつけるようにしています。

call systemリリース時には、

  • 「アプリケーションがあげるエラー」
  • 「サーバー自体のエラー」

の中で重要なものは当然アラート対象としつつ、何が起こるか未知数だった

  • 「TwilioのDebuggerが上げてくれるエラー(これらのうちのいくつか)」

については全てアラート対象としました。 (とはいえ、大量に来たら迷惑すぎるので初期だけは1時間に1回まとめて通知にしていました。)

しばらく運用していると「TwilioのDebuggerが上げてくれるエラー」について、下記のようにいい感じに間引くことができました。

  • call systemを使っているサービスに害がない→アラート対象から外す
    例) Warning 32015 - Twilio

  • ユーザの環境依存で一時的に起こってしまうがcall systemとしては対処できない→call systemを使っているサービス側で対策を考えた上でアラート対象から外す
    例) Warning 32014 - TwilioError 52103 - Twilio

現状を知りながら適切なアラート設定ができたので、一旦アラート対象にして順番に対処する形にしてよかったです。 (いつ起こるかわからないものを、定期的確認しに行くのはしんどかったと思う)

おわり。

List.ofがJacksonでdeserializeできない話

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

JavaにはJacksonというライブラリがあり、Javaコード上のデータをJSONに変換したり(serialize)、逆にJSONJavaコード上のデータに変換したり(deserialize)してくれます。 今回は、このJacksonを使った時にある条件下で詰まった話をしていきます。

Jackson

Jacksonは、Githubのページで以下のように定義されています。

Jackson has been known as "the Java JSON library" or "the best JSON parser for Java". Or simply as "JSON for Java".

端的に言えばJava用のJSONライブラリで、JSONJavaコード上データの相互変換等をしてくれます。 例えば、APIでリクエスト元にデータを返す時にJavaコード上のデータをJSONに変換してから返したり、キャッシュ保存時にデータをJSON化してから保存し、逆にキャッシュからデータを取得する時にJSONJavaコード上のデータに変換する、などで使われたりします。

大体のデータであればJacksonを使えば相互変換ができるのですが、実はまだ対応していない条件があります。 それが List.of です。

List.of と Jackson

List.of はJava9から追加されたメソッドで、immutableなリストを提供してくれます。 すなわち、 List.of で定義したリストは追加や削除、更新することができず、安全に取り扱えるため、非常に使い勝手がよいわけです。

List<String> sampleList = List.of("a", "b");

// 追加できない
sampleList.add("c");

ただ、残念ながらこの List.of はJacksonには対応していません。

// SAMPLE_KEYを使ってキャッシュする
@Cacheable(cacheNames = CacheKeyType.SAMPLE_KEY)
public List<String> getSampleList() {
    return List.of("a", "b");
}

このようにキャッシュをすると、キャッシュからデータを取得する時に Could not read JSON が発生します。 キャッシュデータを見ると以下のように保存されています。

["a", "b"]

一見問題ないように見えますが、実はJacksonではJSON化する際に変換元データの型も保存するようになっており、それをJSONから戻す際に使用するという仕様になっています。 List.of では型を保存してくれないので、Jacksonでは戻すことができないのです。

また、 List.of の使い方によっては型を保存してくれる場合もあるのですが、どうやら List.of で作られた型はまだJacksonで対応されていない型のようで、いずれにしろエラーが起きてしまいます。

解決方法

もっとも安易な解決方法は、 List.of を使わないことでしょう。 例えば上記であれば、 List.of の代わりに Arrays.asList を使うことができます。

// SAMPLE_KEYを使ってキャッシュする
@Cacheable(cacheNames = CacheKeyType.SAMPLE_KEY)
public List<String> getSampleList() {
    return Arrays.asList("a", "b");
}

こちらだとキャッシュでは、以下のように保存されます。

[
    "java.util.Arrays$ArrayList",
    ["a", "b"]
]

こちらであればJacksonに対応している型が保存されるため、Jacksonで問題なく戻すことができます。

可能であれば、

// 空リストを作るとき
Collections.emptyList();

// 1件だけのリストを作るとき
Collections.singletonList("a");

// 2件以上のリストを作るとき
Arrays.asList("a", "b");

とできると、空・1件のみのリストはimmutableになるのでおすすめです。

最後に

List.of は非常に便利ですが、このように落とし穴があるため気をつける必要があります。 状況に合わせて使い分けていきましょう。

なお、2021年9月6日現在では List.of がJacksonで使用できませんが、今後のアップデートで使用できるようになる可能性があります。 また、見つけられていないだけで、実は現状でも使えるようになる設定がある可能性もあります。

あらかじめご了承下さい。

Nimを使ってGUIアプリケーションを作成してみる

今回のあらすじ

前回の記事、「Nim言語を使って簡単に文章の類似度を計算してみる」の続きになります。
今回は文章の類似度を計算するGUIアプリケーションに挑戦していきます。

NiGUI

NiGUIは「cross-platform desktop GUI toolkit」ということで、
Windows, Mac, Linuxで動作するGUI用のライブラリです。
https://github.com/trustable-code/NiGui

アプリケーションを作成する

NiGUIのインストール

nimble install nigui

Macの場合

MacでNiGUIを利用しようとすると、
could not load: libgtk-3.0.dylib
と怒られることがあります。

その場合はbrewなどで必要なライブラリを入れてあげます。

brew install libgtk

準備

せっかくなので前回のコードを使いまわしましょう。
Nim言語を使って簡単に文章の類似度を計算してみるのコードを「ngram.nim」、
今回のコードを「nigui_test.nim」として以下のように保存します。

f:id:taanatsu:20210830183349p:plain

ngram.nimの中身

一応コピペができるように前回のコードをこちらに記載しておきます。
まだ読んでない方はぜひNim言語を使って簡単に文章の類似度を計算してみるを読んでみてくださいね!

import unicode
import tables
import math

proc createNGram*(n: int, text: string): seq[string] =
    ##
    ## n-gramデータを作成します
    ## 
    ## n: n-gramのnに当たる数値
    ## text: n-gramに分解(コーパス)したい文字列
    
    # マルチバイト文字列を扱えるようにテキストをルーン化する(Goとかにもある、アレ)
    let runeText = text.toRunes()

    # 何文字目まで連結させたかを保持しておく変数(Runeは1文字ずつ扱うため、indexでどこまで扱ったかをカウントしておく)
    var index = 0
    # n-gramで何文字ずつの文字列(コーパス)にするかを決めるためのカウント変数
    var cnt = 0
    # n-gramでの文字列を作成する際に利用するtmp変数
    var tmp: string

    while true:
        # cntがn-gramの指定文字数を超えたらそこで切り出す(安全のため<=としている)
        if n <= cnt:
            # resultは暗黙変数(nimでは返り値を定義すると自動的にresult変数が生成される)
            result.add(tmp)

            tmp = ""
            cnt = 0

            # n-gramの特性上一つ前の文字をもう一度利用するので、n-1をしている
            index = index - (n - 1)
            
        
        if text.runeLen() <= index:
            break
        
        # 1文字ずつ連結していく
        tmp = tmp & $runeText[index]

        cnt = cnt + 1
        index = index + 1


proc tf*(corpus: seq[string]): Table[string, int] =
    ##
    ## コーパスの中のTFを計算します
    ## 
    ## corpus: コーパスが格納されたseq配列を指定します

    for c in corpus:
        # 連想配列にその単語があれば1加算、なければその連想配列のキーを作成し、1を代入
        if result.hasKey(c):
            result[c] += 1
        else:
            result[c] = 1

proc cosineSimilarity*(text1: string, text2: string, ngramNum: int): float =
    ##
    ## 文章の類似度を調べます
    ## 
    ## text1: 1つ目の文章
    ## text2: 2つ目の文章
    ## ngramNum: 何gramにテキストを分解するか
    ##

    # 文章をそれぞれコーパスに分解します
    let text1Copus = createNGram(ngramNum, text1)
    let text2Copus = createNGram(ngramNum, text2)

    # text2のTF値を求めます
    let text2Tf = tf(text2Copus)

    # コサイン類似度の計算に必要な分子分母の変数
    var c = 0.0
    var m1 = 0.0
    var m2 = 0.0

    for t1c in text1Copus:
        # text2のコーパスにtext1のコーパスがあるかないかで類似度を計算することにします
        # text2のコーパスにtext1のコーパスがあれば1、なければ0を使います
        var n = 0.0
        if text2Tf.hasKey(t1c):
            n = 1.0
        
        # コサイン類似度に利用する分子分母の数値を計算
        c += (1 * n)
        m1 += 1 * 1
        m2 += n * n
    
    # コサイン類似度の計算
    if m1 == 0 or m2 == 0:
        return 0
    result = c / round(sqrt(m1) * sqrt(m2))

GUIの作成

NiGUIを使ってGUIを作成していきます。

import nigui

app.init()

# ウインドウの作成
var window = newWindow("テキストの類似度を計算する")
# ウインドウのサイズを設定
window.width = 600.scaleToDpi
window.height = 265.scaleToDpi

# ボタンなどを表示する領域の作成
var container = newContainer()
window.add(container)

# テキストエリアを作成
var textArea1 = newTextArea()
container.add(textArea1)
textArea1.x = 0
textArea1.y = 0
textArea1.width = 290
textArea1.height = 200

# テキストエリアを作成
var textArea2 = newTextArea()
container.add(textArea2)
textArea2.x = 310
textArea2.y = 0
textArea2.width = 290
textArea2.height = 200

# 類似度計算ボタンを作成
var calcButton = newButton("類似度の計算")
container.add(calcButton)
calcButton.x = 480
calcButton.y = 220
calcButton.width = 100
calcButton.height = 35

window.show()
app.run()

上記のコードを実行してみます。

$ nim c -r nigui_test.nim

すると、以下のような画面が生成されます。
f:id:taanatsu:20210830183542p:plain

これでGUIの基盤ができました。

ボタンを押したら類似度を計算し、表示させる

Nim言語を使って簡単に文章の類似度を計算してみるのコード(ngram.nim)を読み込みます。

import ngram

次にボタン押下時の処理を追加します。

# 類似度計算ボタン押下時の処理
calcButton.onClick = proc(event: ClickEvent) =
  let textArea1Text = textArea1.text
  let textArea2Text = textArea2.text

  # 2つの文章の類似度を計算
  let similarity = ngram.cosineSimilarity(textArea1Text, textArea2Text, 2)

  # メッセージボックスで、計算した類似度を表示
  window.alert("2つの文章の類似度は" & $similarity & "です。")

これで準備が完了です。
全体のコードを以下に記載いたします。

import nigui
import ngram


app.init()

# ウインドウの作成
var window = newWindow("テキストの類似度を計算する")
# ウインドウのサイズを設定
window.width = 600.scaleToDpi
window.height = 265.scaleToDpi

# ボタンなどを表示する領域の作成
var container = newContainer()
window.add(container)

# テキストエリアを作成
var textArea1 = newTextArea()
container.add(textArea1)
textArea1.x = 0
textArea1.y = 0
textArea1.width = 290
textArea1.height = 200

# テキストエリアを作成
var textArea2 = newTextArea()
container.add(textArea2)
textArea2.x = 310
textArea2.y = 0
textArea2.width = 290
textArea2.height = 200

# 類似度計算ボタンを作成
var calcButton = newButton("類似度の計算")
container.add(calcButton)
calcButton.x = 480
calcButton.y = 220
calcButton.width = 100
calcButton.height = 35

# 類似度計算ボタン押下時の処理
calcButton.onClick = proc(event: ClickEvent) =
  let textArea1Text = textArea1.text
  let textArea2Text = textArea2.text

  # 2つの文章の類似度を計算
  let similarity = ngram.cosineSimilarity(textArea1Text, textArea2Text, 2)

  # メッセージボックスで、計算した類似度を表示
  window.alert("2つの文章の類似度は" & $similarity & "です。")


window.show()
app.run()

動作

f:id:taanatsu:20210830183438p:plain

おわりに

さて、一通りソフトウェアの開発ができました。
Nimはなかなかおもしろい言語ですので、よかったらはまってみてください!
(Excite内でもはまっている人もいます!)

では、また次回!

Terraformの「Objects have changed outside of Terraform」について

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

AWSGCPで環境を構築するとき、Terraformを使用する方も多いかと思います。 今回は、Terraformでたまに起きる「Objects have changed outside of Terraform」について説明していきます。

Terraformとは

Terraformは、公式では以下のように紹介されています。

Terraform is an open-source infrastructure as code software tool that provides a consistent CLI workflow to manage hundreds of cloud services.

端的に言えば「クラウドサービス用のIaC」と言ったところで、AWSGCPなどのクラウドサービスで環境構築する際に、Webコンソール上で作成するのではなくコードとして作成・管理することで、環境構築の再現性を担保したり、コード自体にドキュメントとしての意味を持たせたものになります。

Terraformを実行すると、コードで記述したサービスがクラウドサービス上に作成され、同時に初回実行であれば現在のサービスの状態を保存するための状態管理ファイルが作成されます。 2回目以降にTerraformを実行する場合は、状態管理ファイルを使用することで、前回の実行時との差分などを判定します。

基本的には、コードを全く変更せずに2回連続でTerraformを実行した場合、クラウドサービスの実態と状態管理ファイルの中身には差分は無いはずです。 ですが、たまに「変更された」判定が起きる場合があります。

Objects have changed outside of Terraform

コードが変更されていないにもかかわらず「変更された」判定が起きる場合、「Objects have changed outside of Terraform」というメッセージが表示されます。 このとき、原因としては以下の2点が考えられます。

  1. Terraformを使わずに、以前Terraformで作成したサービスを変更した
  2. それ以外

1の、「Terraformを使わずに、以前Terraformで作成したサービスを変更した」であれば話は簡単で、Terraformのコードを変更された後の状態に修正するか、もしくは修正せずにTerraformを実行することで現状のTerraformのコードの状態にサービス側を戻せば良いでしょう。 もしTerraform外で変更されるのが必然的な状況であれば、 ignore_changes という設定を使い、該当部分の変更をTerraform側で関知しないようにするのもいいかもしれません。

問題は、2の「それ以外」の場合です。

「それ以外」の場合、考えられるパターンとしては

  • 一度のTerraformの実行で複数サービスが作成されるとき、サービス作成順の関係で初回実行ではサービスの状態が完全には状態管理ファイルに反映されず、2回目の実行で「変更された」判定となってしまう
  • 作成に改行文字などを使用するサービスの場合、環境ごとの改行文字の細かい違いによって、サービスで実際に保存されている文字列と状態管理ファイルに保存されている文字列が違っており、2回目の実行で「変更された」判定となってしまう

などが挙げられ、おそらく上記以外にも様々なパターンがあると思われます。

f:id:excite-takayuki-miura:20210830114847p:plain
「それ以外」の例

もちろん可能であればこの表示が出ないようにTerraformを修正したいところですが、なかなか修正が難しいというのが現状だと思います。 こういった場合は、 terraform applyterraform apply -refresh-only などで状態管理ファイルを更新するのが現状だと現実解でしょう。

最後に

初めてこういった差分が出ると、何かミスをしてしまったのかと思ってしまうのではないでしょうか。 そのような状況のときに、この記事が役に立てれば幸いです。

なお上記は Terraform v1.0.5 にて確認したメッセージなので、バージョンが上がることでメッセージが変わったり、もしかしたらこういった差分は起こらないようになっていく可能性があります。 あらかじめご了承ください。

sql serverのflywayの設定について

ご無沙汰しています。株式会社エキサイトの中尾です。

sql serverの本番のデータベースの定義をローカル環境のdockerに再現する際に罠にかかったのでその内容を記載します。

なお、復旧にはflywayを使用します。

やりたいことは以下です。

  • ローカル環境にsql serverを立てる
  • 本番環境のTBL定義を読み込ませる

です。

まず、TBL定義を持ってきます。MSSMSを使用してバックアップスクリプトから取得します。

ここまではうまくいきます。

ローカルに持ってきてflywayを実行するとエラーになります。

以下の対応をすると、flywayが実行されました。

mysqldumpみたいに一回でできたら嬉しいですが、難しいようです。

WITH句の使い方

エキサイトのしばたにえんです。 sqlのWITH句をこないだ初めて使いましたのでその使い方を紹介いたします。

でWITH句を使用することができます。

使い方

以下のような商品販売テーブル(sales)があるとします

code sales_date item_count
A00003 2021-05-17 10:30:00.000 2
A00001 2021-05-17 10:20:00.000 3
A00002 2021-05-17 10:15:00.000 3
A00003 2021-05-17 10:10:00.000 4
A00001 2021-05-17 10:08:00.000 3
A00002 2021-05-17 10:05:00.000 3
A00001 2021-05-17 10:03:00.000 3
SELECT 
    * 
FROM
    sales;

このテーブルにcode毎にitem_countを合計した値も追加して取得する必要があるとします。

code sales_date item_count sum_count
A00003 2021-05-17 10:30:00.000 2 6
A00001 2021-05-17 10:20:00.000 3 9
A00002 2021-05-17 10:15:00.000 3 6
A00003 2021-05-17 10:10:00.000 4 6
A00001 2021-05-17 10:08:00.000 3 9
A00002 2021-05-17 10:05:00.000 3 6
A00001 2021-05-17 10:03:00.000 3 9
-- WITH句を使わない場合
SELECT
    sales.*,
    sum_count
FROM
    sales
JOIN (
    SELECT
        SUM(item_count) AS sum_count,
        code
    FROM
        sales
    GROUP BY
        code
) AS sub_sales
ON
    sales.code = sub_sales.code;

これで取得することができますが、 JOIN句の中に、SELECT句があるので読みにくいです。

WITH句を使った場合

-- WITH句を使う場合
WITH alias AS (
    SELECT
        SUM(item_count) AS sum_count,
        code
    FROM
        sales
    GROUP BY
        code
)
SELECT
    sales.*, alias.sum_count
FROM
    sales
JOIN
    alias
ON
    sales.code = alias.code

WITH句にあるSQL文は、その後のSELECT句より先に実行されます。 WITH句を使わない場合に比べ、JOINにあるSELECT句が外に出るので見やすくなっています。

使う機会があれば使ってみるといいかもしれません。

gradleからgradle実行

こんばんは。エキサイト株式会社中尾です。

gradleからjibを実行する時引数でいろいろ指定すると思いますが、めんどくさいですよね?

※例ではspring.profiles.activeしか指定していません。

./gradlew jib -Djib.container.args=--spring.profiles.active=dev -Djib.to.image=$IMAGE

個人的にargsとか、環境ごとには変わりますが基本コマンド変わらないと思います。 ということ、私は以下のような形でgradleからgradleを実行します。

./gradlew apiAuthBuild -Ptag= bugfix/test

コマンドを実行する前に、aws configreは設定しています。 DOCKER_CONFIGを指定することでloginした際のcredentialsを別ディレクトリに保存ができます。

ext repositoryDev = hoge
ext repositoryNameApiAuth = hogehoge

task apiAuthBuild(type: Exec) {
    doFirst {
        def tag = getProperty('tag').replaceAll( "/","-" )
        def apiAuth = repositoryDev + "/" + repositoryNameApiAuth + ":" + tag
        def ecrLogin = "aws ecr get-login-password | docker login --username AWS --password-stdin " + repositoryDev
        environment "DOCKER_CONFIG" , System.getProperty("user.dir") + "/.docker"
        executable "sh"
        args "-c", ecrLogin + " && " +
                "./gradlew api-auth:jib " +
                "      -Djib.container.args=--spring.profiles.active=dev " +
                "      -Djib.to.image=" + apiAuth

    }
}

aws configreさえ設定していれば、ローカルからでもjenkinsからでもどこでも実行できて便利になると思います。 リリースコマンドはツールに依存しない形にするととても便利になると思います。

Jetpack ComposeのContentColorを活用する

こんにちは。エキサイト株式会社 Androidエンジニアの克です。

今回は、ContentColorを使って色の変更をシンプルにするお話をします。

まずは普通に要素を表示してみる

とりあえず適当なアイコンとテキストを表示するコードを用意しました。

Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center,
) {
    Column {
        Row(
            modifier = Modifier.padding(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                imageVector = Icons.Rounded.Android,
                contentDescription = null,
            )
            Text(text = "サンプル1")
        }
        Row(
            modifier = Modifier.padding(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                imageVector = Icons.Rounded.Android,
                contentDescription = null,
            )
            Text(text = "サンプル2")
        }
    }
}

こちらを実行すると次のような画面になります。 f:id:katsuhiro-ito:20210826173914p:plain:w320

もしこの状態で「上側の要素の背景を黒くしたい」という要件が出てきたらどのようにするでしょうか。

単純にRowの背景を黒くしてみます。

---
Row(
    modifier = Modifier
        .background(Color.Black)
        .padding(8.dp),
    verticalAlignment = Alignment.CenterVertically,
) { 
---

f:id:katsuhiro-ito:20210826173923p:plain:w320

アイコンとテキストは黒のままなので、当然ですが見えなくなってしまいます。

そのためアイコンとテキストの色も変えていきます。

---
Icon(
    imageVector = Icons.Rounded.Android,
    contentDescription = null,
    tint = Color.White,
)
Text(
    text = "サンプル1",
    color = Color.White,
)
---

f:id:katsuhiro-ito:20210826173932p:plain:w320

これで要件を満たすことはできましたが、このやり方には下記のような問題点があります。

  • 要素が増えた場合、全ての要素に色の設定をする必要がある
  • 設定の漏れにより、意図しない表示になりやすい

ContentColorを使うようにすると、こういった問題を解決することができます。

ContentColorとは

既存で用意されているComposableの多くは、デフォルトでContentColorを参照するものが多いです。

@Composable
fun Icon(
---
    tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
)
@Composable
fun Text(
---
) {
    val textColor = color.takeOrElse {
        style.color.takeOrElse {
            LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
        }
    }

例であげたものでは LocalContentColor.currentが使われています。

こちらは現在設定されているContentColorを参照するというもので、このContentColorを変更してあげれば自動的に要素に反映されるということになります。

ContentColorを使ってみる

先程のコードを、ContentColorを使用したものに変更してみましょう。

鍵となるのはSurfaceです。

SurfaceBoxなどと同様に要素を配置できるものですが、名前の通り要素の下に敷くような用途で使用します。

Surfaceでは自身の色とともに、ContentColorを設定することもできるので今回はこちらを活用していきます。

公式のドキュメントでも、背景色の設定にはSurfaceを使用することが記載されています。

developer.android.com

要素の背景色を設定する際は、Surface を使用することをおすすめします。Surface は適切なコンテンツ色を設定します。Modifier.background() で直接呼び出すと適切なコンテンツ色が設定されないため、ご注意ください。

先程のコードをSurfaceに置き換えたものが下記となります。

Surface(
    color = Color.Black,
    contentColor = Color.White,
) {
    Row(
        modifier = Modifier.padding(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(
            imageVector = Icons.Rounded.Android,
            contentDescription = null,
        )
        Text(text = "サンプル1")
    }
}

要素自体に色を指定する必要がなくなったので、どれだけ要素が増えようとも困ることはありませんね。

最後に

個別の対応や同じコードの繰り返しを多用すると、仕様の変更に対応しにくくなったり人為的なミスが発生しやすくなります。 フレームワークが用意してくれている共通化できるような機能を積極的に活用していくようにしましょう。