Skip to content

Latest commit

 

History

History
595 lines (412 loc) · 24.6 KB

プログラミングスタイルガイド.md

File metadata and controls

595 lines (412 loc) · 24.6 KB

プログラミングスタイルガイド

想定読者

Scala学習テキスト の学習が完了していることを前提としています。

スタイルガイドについて

  • スタイルガイドの構造(章立て)は Google Java Style Guide に従う
  • 各ルールにはレベル(MUST、SHOULD、MAY)をつける
    • MUST: 必須
    • SHOULD: 推奨
    • MAY: 任意
  • 自動で調整・チェックされない、注意が必要な項目には ★ を記載
  • WartRemover で自動チェックされるルールは Warts: org.wartremover.warts.Equals のように表記

スタイルガイドの書き方

  • 良い例・良くない例が明確になるようにする
  • 「なぜこの書き方は良くないのか?」といった指針の存在意義が理解できるようにし、知識の応用や指針の改善をしやすくする

ソースファイルの基本事項

ファイル名

★ [SHOULD] ファイル中に定義したクラスやトレイトの名前に合わせる

ファイル名とクラス名が一致していると、GitLab などでファイル名による検索がしやすくなります。

エンコーディング: UTF-8

[MUST] ファイルは UTF-8 でエンコーディングする

EditorConfig により自動的に設定されます。

ソースファイル構造

クラス定義

★ [SHOULD] 原則 1 ファイルには 1 クラスだけ定義する

ファイル名による検索がしやすくなるのに加え、package 変更のリファクタリングがより簡単に行えるようになります。

例外

  • クラスの可視性が private の場合
  • private なクラスをテストコードから参照する目的で package private とした場合
  • sealed trait を実装する場合(sealed trait は 同一ファイル内でのみ継承可能なため)

フォーマット

ScalafmtEditorConfig を使って自動的にフォーマットします

末尾改行

[MAY] 末尾改行を付ける

EditorConfig により自動的に付与されます。

ファイル末尾の改行が無いとファイル末尾に新たなコードを追加する場合、追加したコードの直前の行にも(改行コードを追加されたことにより)差分が出てしまい、レビューする際のノイズになるからです。

末尾カンマ

[MAY] 末尾カンマを付ける

Scalafmt により自動的に付与されます。

リストに新たな要素を追加する場合、追加したコードの直前の行にも差分が出てしまい、レビューする際のノイズになるからです。

// NG!
Seq(
  1,
  2,
  3
)
// OK!
Seq(
  1,
  2,
  3,
)

命名

クラス・トレイト・オブジェクト・型エイリアス

[SHOULD] 最初の文字を大文字にしたキャメルケースにする

Warts: lerna.warts.NamingClass, lerna.warts.NamingObject

// OK
class MyClass
// OK
trait MyTrait
// OK
object MyObject
// OK
type StringList = List[String]

パッケージ・パッケージオブジェクト

[SHOULD] 全て小文字にします

Warts: lerna.warts.NamingPackage

たとえ、複数の単語を組み合わせた名前であってもです!

// OK
package co.tis.jp.lerna
// OK
package object lerna {}
// OK
package co.tis.jp.lerna.transactionmanager
// NG!
package co.tis.jp.lerna.transaction-manager

メソッド・フィールド・変数

[SHOULD] 小文字で始まるキャメルケースにする

Warts: lerna.warts.NamingDef, lerna.warts.NamingVal

// OK
def myFairMethod = ???
// OK
val myFairField = ???
// OK
var myFairVariable = ???

例外

Enumeration を使う場合は大文字で始まるキャメルケースで宣言

object ExampleCode extends Enumeration {
    type ExampleCode = Value

    // OK
    val One: ExampleCode.Value = Value("01")
    val Two: ExampleCode.Value = Value("02")
}

[MAY] 引数なしの副作用のないメソッドには () を付けない

外界の影響を受けない、副作用のないメソッドであることを知らせるために () を省略し、あたかもプロパティであるかのように見えるようにするとコードを読むメンバーの認識負荷を下げられます。

逆に println() のようなメソッドは外界に影響を与えるため副作用があるとみなされ、() を付けるべきです。

// NG!
def size(): Int

// OK
def size: Int

型パラメータ

★ [MAY] アルファベット1文字でAから始める

// OK
class List[A] {
  def map[B](f: A => B): List[B] = ???
}

プログラミングの実践

コメント

★ [SHOULD] クラスやメソッドなどにコメントを残す

適切なコメントはクラスやメソッドを使う他の開発者のヒントになります。

  • 使い方
  • 設計の意図

などを書き留めておくようにしましょう。

言語仕様

[SHOULD] 継承したメソッドを再定義する場合は override を付ける

Warts: org.wartremover.contrib.warts.MissingOverride

ミックスインしたトレイトや、継承した親クラスのメソッドがリファクタリングによって名前変更や削除が行われた際、実装クラス側でその変更への追従が漏れていた場合、override を付けておくと実装クラス側でコンパイルエラーになるため、問題に気づけるようになります。

trait Example {
  def doSomething
}

class ExampleImpl {
  // NG!
  def doSomething = ???
}

class ExampleImpl {
  // OK
  override def doSomething = ???
}

[SHOULD] ==/equals の代わりに === を使う

Warts: org.wartremover.warts.Equals

==equals は異なる型のオブジェクトを比較していてもコンパイルエラーにはなりません。 リファクタリングなどで比較対象の片方の型が変わった際、常に false を返すようになるため不具合の原因になります。

コンパイラーが不具合を検出できるように === を使います。

// `===` を使うために必要
import jp.co.tis.lerna.payment.utility.lang.All._
// もしくは import lerna.util.lang.Equals._

"1" === 2   // コンパイルエラー
"1" === "2" // コンパイルできる

★ [SHOULD] Enumeration は使わず case object を使う

Enumeration はパターンマッチの網羅性チェックが行われず不具合に気づきにくいため、sealed traitcase object を用いて列挙型を定義しましょう。

sealed trait WeekDay
case object Mon extends WeekDay
case object Tue extends WeekDay
case object Wed extends WeekDay
case object Thu extends WeekDay
case object Fri extends WeekDay
case object Sat extends WeekDay
case object San extends WeekDay

例外

列挙型からコード値、コード値から列挙型の変換を両方行う必要がある場合は Enumeration のほうが簡潔に実装できるため利用を検討する。

object WeekDay extends Enumeration {
  type WeekDay = Value
  val Mon = Value(1)
  val Tue = Value(2)
  val Wed = Value(3)
  val Thu = Value(4)
  val Fri = Value(5)
  val Sat = Value(6)
  val San = Value(7)
}
WeekDay.Mon.id // => 1
WeekDay(1) // => Mon

Enumeration で各要素にメソッドやフィールドを持たせる方法は下記の記事を参照。

★ [SHOULD] require で事前条件を表現する

メソッドの処理が引数に特定の条件を要求する場合は、require を使って事前条件を表現しましょう。

こうすることでこのメソッドが要求する条件がわかりやすくなるだけでなく、実行時にいち早くエラーに気づくことができるようになります。

// `require` を使うために必要
import jp.co.tis.lerna.utility.lang.All._
// もしくは import jp.co.tis.lerna.utility.lang.Assertions._

def doSomething(times: Int) = {
    require(times > 0) // times に 0 よりも大きい数字を指定しなければならないことを表現
}

このようなプログラミングスタイルは一般的に契約プログラミングと呼ばれています。

契約プログラミング(けいやくプログラミング、Programming By Contract)または契約による設計(けいやくによるせっけい、Design By Contract)とは、プログラムコードの中にプログラムが満たすべき仕様についての記述を盛り込む事で設計の安全性を高める技法

契約プログラミング - Wikipedia

制御構造

[SHOULD] 各メソッドの循環的複雑度は 10 以下に抑える

Warts: lerna.warts.CyclomaticComplexity

条件分岐やループ処理の数によって複雑度を測る指標です。 循環的複雑度を 10 以下にするとバグ発生確率を 30% 以下抑えられると言われています。

処理を他のメソッドに切り出すなどして、各メソッドの複雑度を下げましょう。

非同期処理

[MUST] Await.result/Await.ready を使わず Future のメソッドや for 式を使う

Warts: lerna.warts.Awaits

Future を用いた並列処理では大抵の場合、スレッド数に上限のあるスレッドプールを利用して処理が実行されます。Await.resultAwait.ready は結果を待つ Future の処理が完了するまでの間、スレッドを専有します。その結果、CPU に余裕があるにも関わらずスレッドプールが枯渇し、アプリケーション全体のパフォーマンスが劣化してしまう可能性があります。

FuturemapflatMapforeach といったメソッドを用いたり、for 式を用いることで処理順序の制御を行うようにしましょう。

// NG!
val result = Await.result(future)
val nextValue = result + 1

// OK
val nextValue =
  future.map { result =>
    result + 1
  }

// OK
val nextValue =
  for {
    result <- future
  } yield result + 1

★ [SHOULD] scala.concurrent.ExecutionContext.Implicits.global は使わず、Akka の executionContext を使う

Future を使った処理を実装していると、Scala コンパイラーに scala.concurrent.ExecutionContext.Implicits.global をインポートするように指示されることがありますが(下記参照)、Akka を使っている場合は、Akka の executionContext を使うことが推奨されます。

error: Cannot find an implicit ExecutionContext. You might pass an (implicit ec: ExecutionContext) parameter to your method or import scala.concurrent.ExecutionContext.Implicits.global.

scala.concurrent.ExecutionContext.Implicits.global はアプリケーション全体で使われる可能性があるため、他の部分が原因の(スレッドプールの枯渇といった)不具合の影響を受けやすくなってしまいます。Akka の executionContext を使うと、処理ごとにスレッドプールを分離したりといったことが設定ファイルの修正だけで実現できるので、そのような不具合の影響を受けにくくすることが簡単にできます。また、パフォーマンスチューニングも容易になります。

// NG!
import scala.concurrent.ExecutionContext.Implicits.global

// OK
// Actor の外側では ActorSystem から executionContext を取り出す (system は ActorSystem[_])
import system.executionContext

// OK
// Actor の内部では context.executionContext を使う
import context.executionContext

★ [MUST] Future で返ってきた結果は全て for 式 や Future.sequence で合成する

Future 同士を合成していないと例外が握りつぶされ、エラーハンドリングできなくなってしまいます。 もし Future 内の処理でエラーが発生しても、最悪の場合スタックトレースすら出力されないため、エラーを検知することが困難になります。

for 式や Future のメソッドを使って Future 同士を合成するようにしましょう。

def doSomething(): Future[Int] = ???

// NG! - Future が合成されていない
try {
    doSomethingA()
    doSomethingB() // ⇐ 処理中に例外が発生しても握りつぶされる
    sayHello()
} catch { 
    // Future の処理中に発生した例外は catch されないため、ほぼ無意味なコード
    case NonFatal(cause) =>
        log.error(cause)
}

// OK - Future が合成されている
val futureCountA: Future[Int] = doSomethingA()
val futureCountB: Future[Int] = doSomethingB()
val futureSum: Future[Int] =
    for {
      countA <- futureCountA
      countB <- futureCountB
    } yield countA + countB

futureSum.onComplete {
  case Success(_) =>
    sayHello()
  case Failure(cause) =>
    // 合成しておくことで Failure として例外をハンドリングできる
    log.error(cause)
}

Akka Actor

★ [MUST] ShardRegion にメッセージを ! (tell) する場合はタイムアウトを実装する

ShardRegion 宛にメッセージを送った場合、ネットワーク障害などでメッセージが届かないことがあります。送ったメッセージの応答を待つような実装をした場合は応答が返ってこないことを考慮して、タイムアウトを実装し、永遠に待ち続けることによる不具合を回避しましょう。

永遠に待ち続けた場合、メモリリークや処理が進まないことによるユーザビリティの低下など様々な障害の原因になります。

★ [SHOULD] メッセージには Exception を使わず失敗を表すメッセージを実装する

アクターが何かしらの処理結果を通知するメッセージを作成する場合は、成功・失敗を表現するメッセージを個別に作成し、共通の sealed trait をミックスインすることを推奨します。こうすることで後記するシリアライズの問題を回避しつつ、メッセージのハンドリング漏れを(sealed trait により)コンパイラがチェックできるようになり、より安全な実装が行えます。

一般的に失敗を表現するためのオブジェクトとして Exception (を継承したクラス)が用いられますが、アクターのメッセージで Exception を使うと、メッセージのシリアライズで問題が発生します。 デフォルトでは ExceptionThrowable) のシリアライズには Java Serializer が使われます。 しかし、セキュリティや互換性の観点から Akka では Java Serializer の使用は推奨されません。 独自のシリアライザーを実装することで Exception に Java Serializer が使われることを回避できますが、保守コストが高くなったり、実装漏れによって実行時例外が発生するリスクもあるため推奨しません。

Behaviors.receiveMessage[Command] {
  case Ping(replyTo) =>
    replyTo ! ProcessFailed(code = 1)             // OK
//  replyTo ! BadResponse(new RuntimeException()) // NG!
    Behaviors.same
}

sealed trait Command
case class Ping(replyTo: ActorRef[Response]) extends Command
sealed trait Response
case class ProcessSucceeded() extends Response
case class ProcessFailed(code: Int) extends Response
// NG!
// case class BadResponse(cause: Throwable) extends Response

参照

Akka Cluster Sharding

★ [SHOULD] Cluster Sharding の typeName を一覧できるクラスを作る

Akka Cluster Sharding の種別を特定する typeName にはそれぞれユニークなものを設定する必要があります。ShardRegiontypeName が重複した場合、メッセージが予期しないアクターに届くことがあります。大抵、この問題が発生した場合はログで unhandled message が報告されます。

ClusterSharding を複数起動する場合は typeName が重複しないよう一覧できるクラスを定義しておくことを推奨します。

object ClusterShardingTypeNames {
  // typeName はここに列挙していく
  // typeName の文字列と変数名を合わせるとコンパイラが重複チェックしてくれる
  val fizz = "fizz"
  val buzz = "buzz"
}

val FizzTypeKey = EntityTypeKey[Fizz.Command](ClusterShardingTypeNames.fizz)

val fizzRegion: ActorRef[ShardingEnvelope[Fizz.Command]] =
  ClusterSharding(system).init(Entity(FizzTypeKey)(createBehavior = entityContext => Fizz(entityContext.entityId)))

Blocking I/O

★ [SHOULD] Blocking I/O には専用のスレッドプール(ExecutionContext)を使う

Blocking I/O とは、外部システムへの API 呼び出しやファイルの書き込みの応答を待つときにスレッドがブロックされる入出力操作のことです。 API 呼び出しなどの処理結果が Future で受け取れない場合は Blocking I/O になっていると考えられます。

Akka では Blocking I/O は推奨されません。 Blocking I/O はその特性上、多くのスレッドを消費します。 スレッド数が増え過ぎると、CPU のコンテキストスイッチが多く発生し CPU 使用率が低いにも関わらず性能が出ないという問題が生じます(C10K問題)。 この問題を回避するため、Akka は応答待ちでスレッドをブロックしない Non-Blocking I/O を前提とし、 少数のスレッドを使いまわすことでコンテキストスイッチを最小限に抑え、効率よく処理するというポリシーのもと構成されています。

Akka がデフォルトで処理に使うスレッドプールはこのポリシーによりスレッド数の上限が低く設定されています。 万一 Blocking I/O によりスレッドが全てブロックされた状態になると CPU には余裕があるにも関わらず他の処理が詰まったり全く実行できない状態に陥ります。

このような問題を回避するため、Blocking I/O をする処理はデフォルトのスレッドプールとは隔離された Blocking I/O 専用のスレッドプールを使うよう実装すべきです。 Non-Blocking I/O で処理する手段がある場合は、まずはその手段の採用を検討してください。

Blocking I/O 専用のスレッドプールを作成するには、Akka の機能が利用できます。

Solution: Dedicated dispatcher for blocking operations ― Dispatchers • Akka Documentation

上記のドキュメントを参考に、Blocking I/O 用のスレッドプール(ディスパッチャ)を作成し、次のコード例ように Future でラップすると、Blocking I/O の処理を専用のスレッドプールで処理させることができ、デフォルトのスレッドプールから隔離できます。

private val system: ActorSystem[_] = ???

/**
  * Blocking I/O 専用のスレッドプール
  */
private val blockingDispatcher: ExecutionContext =
  system.dispatchers.lookup(DispatcherSelector.fromConfig("my-blocking-dispatcher"))

/**
  * Blocking I/O の処理を Future でラップし、Blocking I/O 専用のスレッドプールで処理させる
  */
def execute(): Future[Result] = Future {
  // Blocking I/O の処理を実行
  val result = executeBlockingOperation()
  Result(result)
}(blockingDispatcher) // Blocking I/O 専用のスレッドプールを明示的に指定

システム日付取得

★ [MUST] システム日付(ゾーン情報なし)は LocalDateTimeFactory から取得する

システム日付は lerna.util.time.LocalDateTimeFactory を使って日付を取得してください。

import jp.co.tis.lerna.utility.time.LocalDateTimeFactory

class MyComponent(dateTimeFactory: LocalDateTimeFactory) {

  dateTimeFactory.now() // 処理時点のシステム日付を取得
}

ユニットテストで簡単に日付を固定値に変更できます。

テスト例: FixedLocalDateTimeFactorySpec.scala

設定値

★ [SHOULD] 設定値はコンストラクタで読み込む

設定ミスはアプリケーションの起動時に検知できる状態が望ましいです。 アプリケーションを起動するだけでほとんどの設定ミスを検知できる状態だと、設定ミスにより業務処理実行時にランタイムエラーが発生するリスクが少ないからです。 設定値をコンストラクタで読み込むように実装することで、アプリケーションの起動時に行われる DI コンテナの初期化処理でエラーを検知できます。

import scala.jdk.DurationConverters._

class MyComponent(config: Config) {
  // MyComponent のインスタンスはアプリケーション起動時に作成される
  // 設定値の不備などで設定値の読み込みに失敗した場合、MyComponent の初期化に失敗するためアプリケーションの起動自体が失敗する
  val requestTimeout: FiniteDuration = config.getDuration("com.example.app.my-component.request-timeout").toScala
}

設定項目が複数ある場合は、設定値をまとめるクラスを作成すると便利です。

ここでもクラス内での設定値の読み込みは val で行い、初期化処理でエラーを検知できるようにしておきます。

import scala.jdk.DurationConverters._

class MyComponentConfig(config: Config) {
  private val componentConfig: Config = config.getConfig("com.example.app.my-component")
  
  val requestTimeout: FiniteDuration = componentConfig.getDuration("request-timeout").toScala
  val attempt: Int = componentConfig.getInt("attempt")
}

作成した Config 読み込み用のクラスはコンストラクタから受け取るようにすると DI により自動インジェクションされます。

class MyComponent(config: MyComponentConfig) {
  val requestTimeout = config.requestTimeout
}

テスト

★ [SHOULD] StandardSpec を使ってテストを実装する

シンプルなアサーションの記述でエラーが下記のように見やすくなるので StandardSpec を使ってテストを実装することを推奨します。

java.lang.AssertionError:

expect("2" === "1")
        |   |
        2   false

実装例: StandardSpecExample.scala

開発環境

★ [SHOULD] IntelliJ IDEA の警告を無視しない

IntelliJ IDEA はタイプミスやあまり良くない書き方を警告してくれます。

IntelliJ IDEA のテーマを Material Theme UI の Dark 系にすることで警告が目立つようになります。

インストール

  • Settings > Plugins > Browse Repositories > 「Material Theme UI」

配色設定

  • Settings > Editor > Color Scheme > Scheme

Material Oceanic など Dark 系の配色を設定。

※ 背景が明るい Material Lighter などは警告に気づきづらい