1クール続けるブログ

とりあえず1クール続けるエンジニアの備忘録

linux/amd64でlinux/arm64アーキテクチャのイメージをPullする

AWSでm6g/r6g/c6gとarmのCPUが乗った新世代のインスタンスタイプが続々とGAになりましたね。
自分の運用している環境でコストとパフォーマンスにどのくらいに改善が見られるのか楽しみですね。EKSのOptimized AMIのARM版はまだGAされていませんが、もしされることになれば、是非そちらにも導入してみたいところ!

皆さんは、node-exporterなど、DockerHubから直接Pullしても動かせるものはどうしているのでしょうか。
Amazon ECRなどはSLAが存在しているし、障害があったときにサポートへの問い合わせから状況把握が出来たりするので、マネージドなプライベートコンテナレジストリにPushしておくのが良いのではないかなと思っております。

今後出てくるユースケースとして、「linux/amd64アーキテクチャインスタンス(例えばJenkinsとか)からlinux/arm64のコンテナイメージをDockerHubからpullして、ECRなりにpushする」というのが出てくるのではと思います。

pushはともかく、docker pull imageで取得してくるイメージはdockerコマンドを発行するアーキテクチャに依存してしまうので、linux/amd64でarmイメージをpullするにはひと手間加える必要がありますね。

experimentalの --platform オプションを使う

Docker for Macの場合には下記のようにPrefernces > Docker Engineexperimentaltrueにしておく。

f:id:jrywm121:20200613150207p:plain

Linuxの場合は、--config-fileの指定が無ければ、/etc/docker/daemon.jsonを編集する。

docs.docker.com

docker pull --platform linux/arm64 nginx

docker inspect nginx@sha256:21f32f6c08406306d822a0e6e8b7dc81f53f336570e852e25fbe1e3e3d0d0133 | grep -E "Os|Architecture" 
        "Architecture": "arm64",
        "Os": "linux",,

コンテナイメージハッシュを指定してpullする

DockerHubのAPIを理由してlinux/arm64のイメージを取得する。

ARM64_DIGEST=$(curl -s 'https://registry.hub.docker.com/v2/repositories/prom/node-exporter/tags/' |
    jq -r '.results[] | select(.name == "v1.0.0") | .images[] | select(.os == "linux" and .architecture == "arm64") | .digest')
docker pull prom/node-exporter@${ARM64_DIGEST}

platformがexperimentalでなくなってくれると非常に嬉しいなあと思っています。

TerraformでGKEクラスタ構築

業務ではdeployment managerというGCPから公式が提供している、Google Cloudリソースの作成と管理を自動化するインフラストラクチャデプロイサービスを利用して、Kubernetesクラスターやノードプールの作成を行っています。

ですが、GCPの環境構築においてはTerraformを利用している企業さんがかなり多いという認識をしております。 せっかくなのでプライベートでクラスターを作成する際には、Terraformを利用してみたいと思います。

作成したい構成

結構ケチケチしている性分なので、なるべく安くしたいところ…!

  • Google Kubernetes Engineのゾーンクラスタ
    • デフォルトで作成されるのがゾーンクラスタで、1アカウントにつき1つはAlwaysFree枠で無料になる
    • 実際に本番利用するときには、リージョンクラスタにしてMasterの冗長性を確保した方が良い、EKSのデフォルトはリージョンクラスタのはず
  • n1-standard1のノードプール
    • f1-microは30日間分無料なのだけれどスペックが満たせない

無料トライアルでもらえる$300のクレジットを利用して、はみ出したGCEとロードバランサの料金を払いたいと思います。
…待って。Stackdriverの料金もここに乗っかりそう🤔 Stackdriverのログも保存させないように設定を変更させておかないと!と思っていたのですが、50GBまでは無料なので一旦無視します。

TerraformでProvisionする準備

参考

learn.hashicorp.com

CLIをインストール

Download Terraform - Terraform by HashiCorp にアクセスして、アーキテクチャ/OSを選択し、CLIのリンクアドレスをコピーする。

$ wget https://releases.hashicorp.com/terraform/0.12.25/terraform_0.12.25_darwin_amd64.zip
$ unzip terraform_0.12.25_darwin_amd64.zip                                                                                                 
$ vi $HOME/.zshrc    # CLIのバイナリにPATHを通しておく
export PATH=$HOME/.terraform/bin:$PATH

$ terraform    # 叩けるか確認                                                                                                                                       
Usage: terraform [-version] [-help] <command> [args] ...(省略)

GCPをセットアップ

試しにTerraformでリソースを作成してみる

下記のように、ファイルを作成する。

// リソースの作成と管理を担当するproviderを設定する
// 複数のクラウドベンダなどを利用する場合には、複数のproviderブロックが存在しうる
provider "google" {
  version = "3.5.0"

  credentials = file("<クレデンシャルキーのファイル>.json")

  project = "<PROJECT_ID>"
  region  = "us-central1"
  zone    = "us-central1-c"
}

// インフラストラクチャ内にあるリソースを定義
// ブロック開始前の構成は、<リソースタイプ> <リソース名> となっていて、
// リソースタイプ google_compute_network の接頭辞がproviderを表す。
// <リソースタイプ>.<リソース名> はリソースIDとして扱われ、他のリソースから参照できる
resource "google_compute_network" "vpc_network" {
  name = "terraform-network"
}

下記のようにターミナルで操作を進めていく。
新しい設定の場合もしくはバージョン管理化にあるファイルをチェックアウトした後には、terraform initを実行する。このコマンドは、ローカルの設定やデータを初期化する。これは、main.tfのあるディレクトリで実行する。
terraform planでこの設定を適用した場合に、どのような操作が行われるかが確認できる。kubectlにおける--dry-runオプションのようなもの。
terraform applyで実際にリソースの作成/変更/削除が行われる。

$ terraform init                                                                  

Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "google" (hashicorp/google) 3.5.0...

Terraform has been successfully initialized!
...(省略)

$ terraform plan                                                                  
...(省略)
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # google_compute_network.vpc_network will be created
  + resource "google_compute_network" "vpc_network" {
      + auto_create_subnetworks         = true
      + delete_default_routes_on_create = false
      + gateway_ipv4                    = (known after apply)
      + id                              = (known after apply)
      + ipv4_range                      = (known after apply)
      + name                            = "terraform-network"
      + project                         = (known after apply)
      + routing_mode                    = (known after apply)
      + self_link                       = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

$ terraform apply
...(省略)
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

google_compute_network.vpc_network: Creating...(省略)

実際に出来ているのが確認できました!

f:id:jrywm121:20200607081807p:plain

インスタンスの作成もやってみる。下記の設定をmain.tfに付け加える。f1-microなのでAlways Free枠です。お値段はかかりません。

resource "google_compute_instance" "vm_instance" {
  name         = "terraform-instance"
  machine_type = "f1-micro"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-9"
    }
  }

  network_interface {
    // ここで別のリソースの参照ができる
    network = google_compute_network.vpc_network.name
    access_config {
    }
  }
}

VMインスタンスも作成できていますね

f:id:jrywm121:20200607084935p:plain

最後にお片付けをしておきましょう

$ terraform destroy

Kubernetesクラスタを作成する

www.terraform.io

www.terraform.io

クラスターを作成する上で下記の2つのような方針を考えています。

  • kubectlコマンドで必要となるkubeconfigはgcloud container clusters get-credentials cluster-nameにて取得する
  • terraformのデフォルトはルートベースでのクラスター作成だが、今回はGCPのデフォルトであるVPC-Nativeのクラスターを作成する

実際にファイルを作成してapplyする前に APIの有効化をやっておくべき。

ファイルは下記のように作成した

provider "google" {
  version = "3.5.0"

  credentials = file("<クレデンシャルキーのファイル>.json")

  project = "<PROJECT_ID>"
  region  = "us-central1"
  zone    = "us-central1-c"
}

resource "google_container_cluster" "primary" {
  name = "my-gke-cluster"

  // ゾーンを指定するとゾーンクラスタ、リージョンを指定するとリージョナルクラスタ
  location = "us-central1-c"

  // ノードプールのリソース設定を包含すると密結合になり変更が難しくなる場合がある
  remove_default_node_pool = true
  initial_node_count       = 1 // ↑がtrueのとき使用されないが"1"をセットしておく必要あり

  network    = "default"
  subnetwork = "default"

  // VPC-Nativeの場合には指定
  ip_allocation_policy {
    cluster_ipv4_cidr_block  = "/16" // podのIPアドレス範囲
    services_ipv4_cidr_block = "/22" // ServiceのIPアドレス範囲
  }

  // private_cluster_config も入れたほうが良い

  // usernameとpasswordを空で作ればBasic認証はdisableになる
  // このブロックを指定しなければ、GCPのユーザ名を利用し自動生成する
  master_auth {
    username = ""
    password = ""

    client_certificate_config {
      issue_client_certificate = false
    }
  }
}

resource "google_container_node_pool" "primary_preemptible_nodes" {
  name     = "my-node-pool"
  location = "us-central1-c"
  cluster  = google_container_cluster.primary.name

  // node_countと同時に使用するべきではない
  autoscaling {
    min_node_count = 0
    max_node_count = 1
  }

  management {
    auto_repair  = true  // ノードの自動修復は有効化
    auto_upgrade = false // 自動アップグレードは無効化
  }

  node_config {
    preemptible  = true
    machine_type = "n1-standard-1"

    labels = {
      app = "web"
    }

    // taintsも設定できるが、このフィールドの変更がノード再生成のトリガーと
    // なるので、ここで管理すべきではない

    metadata = {
      disable-legacy-endpoints = "true"
    }

    oauth_scopes = [
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring",
    ]
  }
}

いざ!Provisionだ!

$ terraform apply
$ gcloud container clusters get-credentials my-gke-cluster
$ kubectl get svc                                                                        
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.112.0.1   <none>        443/TCP   11m

kubectlが通りました!コンソールも確認してみましょう!想定どおりです!

f:id:jrywm121:20200607103350p:plain

感想

普段利用しているdeployment-managerよりもシンプルで分かりやすい印象を受けました。
テンプレート化して変数とか当て込んだりしようとすると構成がもう少し複雑になってしまうのかもしれませんが、各クラウドベンダが同じ記法でインフラストラクチャを構成できるのは魅力ですね。

Accept-CharsetとContent-Typeの不一致でJerseyは404を返す

かなりハマってしまったので残しておきたいと思います。
とあるWebクライアントから通信を行った際に、ChromeFirefoxなどのモダンブラウザではステータスコード 200 OK を返していたリクエストパスで、ステータスコード 404 NotFound を返す事象と遭遇しました。使用していたフレームワークはJerseyで、前段にWebサーバとしてApacheを利用していました。

原因調査

今回、対象のWebクライアントが手元にない状態でした。リモートでの在宅勤務ということもあり、手元に持ってくることも難しい状況です。そのため少し泥臭くサーバサイドから調査を行っています。下記のように調査を進めていきました。

  • tomcatアクセスログを出力し、「tomcatまで通信は来ているか?」「tomcatの時点で返却されているステータスコードは何か?」を確認
  • Javaアプリケーションでどこまで処理が到達しているかをデバッグ出力で確認
    • WriterInterceptorまで到達しているので、レスポンスの書き込み時に404が返却されていることがわかる
    • JerseyのFilter系を理解するときには毎回、こちらの資料にお世話になっている、とっても分かりやすい
  • リクエストのヘッダ差分を確認
    • 直上の調査結果からJersey側で処理している層で404を返却していることがわかったので、X- で始まる独自定義ヘッダでない部分から見ていく
    • 他のWebクライアントから送信していないヘッダaccept-charsetが見つかる、以前にReal World HTTPを読んだ際に「モダンブラウザではほとんど送信していない」とあったので臭いなと感じる
    • accept-charsetを404のリクエストと同じように設定しcurl叩くと再現が出来た!

accept-charsetで指定している文字コードと、Content-Typeで指定している文字コードは同じだが、それぞれUPPRER_CASEとlower_caseとなっており厳密には異なっていた。その事実から、Content-Typeで指定している文字コード以外の文字コードaccept-charsetヘッダに設定してリクエストを投げると100%再現することができ、Accept-CharsetとContent-Typeの不一致が原因であることが分かりました。

Accept-CharsetとContent-Type

一旦立ち止まり、それぞれについて調べてみる

リクエストヘッダ:Accept-Charset

Accept-Encodingヘッダなどと同じコンテントネゴシエーションのためのもの。MDNを観て分かる通り、モダンブラウザでは送信していないです。どのブラウザも全Charsetのエンコーダーを内包するので事前にネゴシエーションする必要はなくなったからだそう、なるほどだ。

developer.mozilla.org

Charsetの指定は、IANAで管理されている。このIANAで指定/推奨されている値になっていれば不一致は起こらなかったはず…! ちなみに本来、このヘッダは無視されるか、ネゴシエーションに失敗した場合には406 Not Acceptableを返すのだそう。

www.iana.org

レスポンスヘッダ:Content-Type

このヘッダには、MIMEタイプとキャラクターセットの宣言が含まれます。WEBブラウザがどのようにファイルの種類、文字コードを区別し読み込むために必要なものです。HTMLの場合はドキュメント内に記述することも可能。ローカルに保存して再表示することもあるので、併用するべきとのこと。
Jerseyではリクエストハンドラとなるメソッドに付与する@Produces()で指定する。

Jersey はどこで404を返したのか

例外発生箇所は下記です。templateが解決できなかったとして404 NotFoundとしています。
同じリクエストパスで他のWebクライアントからのリクエストはtemplateが解決できているので、不思議です。もう少し深堀りしていきます。

jersey/ViewableMessageBodyWriter.java at master · jersey/jersey · GitHub

if (resolvedViewable == null) {
    final String message = LocalizationMessages.TEMPLATE_NAME_COULD_NOT_BE_RESOLVED(viewable.getTemplateName());
    throw new WebApplicationException(new ProcessingException(message), Response.Status.NOT_FOUND);
}

templateProcessorsとmediaTypesという2重ループの中でViewが解決されるのですが、404 NotFoundになるときにはmediaTypesが要素数が0で返り、Viewが解決できていないということになります。
そのmediaTypesを生成しているのが下記メソッドです。この処理で呼ばれているselectVariantsが肝になります。

jersey/VariantSelector.java at master · jersey/jersey · GitHub

public static List<Variant> selectVariants(final InboundMessageContext context,
                                            final List<Variant> variants,
                                            final Ref<String> varyHeaderValue) {
    LinkedList<VariantHolder> vhs = getVariantHolderList(variants);

    final Set<String> vary = new HashSet<>();
    vhs = selectVariants(vhs, context.getQualifiedAcceptableMediaTypes(), MEDIA_TYPE_DC, vary);
    vhs = selectVariants(vhs, context.getQualifiedAcceptableLanguages(), LANGUAGE_TAG_DC, vary);
    vhs = selectVariants(vhs, context.getQualifiedAcceptCharset(), CHARSET_DC, vary);      // ここの処理でvhsの要素が0になる
    vhs = selectVariants(vhs, context.getQualifiedAcceptEncoding(), ENCODING_DC, vary);

    if (vhs.isEmpty()) {
        return Collections.emptyList();
    } else {
        // 省略
    }
}

selectVariantsの処理が下記です。dimensionChecker.isCompatible(a, d)) というメソッドで、Accpet-CharsetのヘッダValueとContent-Typeで指定しているcharsetが一致しているかを確認しています。

jersey/VariantSelector.java at master · jersey/jersey · GitHub

for (final T a : acceptableValues) {
    // 省略
    while (iv.hasNext()) {
        final VariantHolder v = iv.next();

        // Get the dimension  value of the variant to check
        final U d = dimensionChecker.getDimension(v);  // dに文字コードが入る、例えば "utf-8"

        if (d != null) {
            vary.add(dimensionChecker.getVaryHeaderValue());
            // Check if the acceptable entry is compatable with
            // the dimension value
            final int qs = dimensionChecker.getQualitySource(v, d);
            if (qs >= cqs && dimensionChecker.isCompatible(a, d)) {  // ここで判断
                // 省略
            }
        }
    }
}

dimensionChecker.isCompatible(a, d)の処理は実際どんな感じかというと、下記のようになっており単純なString.equalsを使用しているだけです。そのため大文字/小文字を厳密に判定する形となっています。

jersey/Token.java at master · jersey/jersey · GitHub

public final boolean isCompatible(String token) {
    if (this.token.equals("*")) {
        return true;
    }

    return this.token.equals(token);
}

まとめ

ググっても全然出てこなかったので調査がとても大変だった。フレームワーク側の挙動を見るのかなり大変だ。
Jerseyのバージョンが2.11で結構古めだったので起こってしまったのかもなあとも思います。
貼ったJerseyのリンクは旧リポジトリのほうで、現在は以下のようにEclipse Foundataion管理になっています。

github.com

Sassもさらっと学んだ

いつもはDockerやKubernetesとべったりなのですが、最近はWEBフロントエンド+サーバサイドの開発をやっています。
サーバサイドは新卒で入った会社から慣れ親しみがありましたが、その会社のクライアントサイドがWindowsのデスクトップアプリ(※)だったため、WEBフロントエンドは未経験でした。
※ ちなみにWPFやUWPではなく、WinForms でした

昨今、cssメタ言語はWEBフロントエンド開発には欠かせないと聞きました。Vue.jsで対話的にテンプレートとか作成するときにも「Sass使う?それともStylus?」なんて聞かれた覚えがあります。
Stylusの方がパット見好みっぽいですが、会社で使っているのはSassなので勉強することにします。すでに軽く学んで実装とかも行っているんですが、学びなおしてリファクタに活かしたいというお気持ちです。

Sassにはいくつか種類がある

風の噂でSassはRubyが無いと動かせないという話を聞いていたのですが、それは全くの間違いで、むしろ2019年3月26日をもってサポートを終了していました。主な理由としてあげられているのは、要求されるパフォーマンスにRubyでの実装が追いつかなかったことのようです。また、Node.jsがフロントエンドの主流になっていったことも理由の一つとして挙げられています。

サポートされているのは以下の2つ

  • DartSass
  • LibSass

DartSassは現在のメイン実装で、最も早く新しい機能が追加されます。
Dartに書き換える理由は Sass: Announcing Dart Sass にて記載があります。少し掻い摘まみます。

RubySassは遅くRubyユーザ以外にはインストールが面倒であるものの機能追加が簡単、対してLibSassは速くて移植性が高いものの機能追加が難しいといった特徴を持っていました。2つは補完関係にあったが、どちらのソリューションも必要とされるほど良いものではなかったそうです。
Dartを選んだのは、高速かつ作業が簡単であったこと、JavaScriptとの互換性があるため既存のツールやビルドシステムにJS版を取り入れることができるという点だそうです(作者がDartチームだったことも大きそう)。

Sassの環境構築

webpackで1つのjsファイルにバンドルするケース
初めてwebpackを直接触った気がします、以前にAngularとかちょろっと触ったときには設定ファイルが自動生成されて、直接書き換えたことはなかったためです。
webpack自体は、英語のドキュメントを読み解けなかったので、こちらで勉強しました。

参考

webpack.js.org

$ npm init
$ npm install sass-loader style-loader css-loader sass webpack webpack-cli fibers --save-dev
$ mkdir -p src/js
$ mkdir src/scss
$ code webpack.config.js
$ webpack --mode development --watch   # 変更をウォッチ
const path = require('path');

module.exports = {
    mode: 'development',
    entry: './src/js/app.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [{
            test: /\.s[ac]ss$/i,
            use: [
                // Creates `style` nodes from JS strings
                'style-loader',
                // Translates CSS into CommonJS
                'css-loader',
                // Compiles Sass to CSS
                'sass-loader',
            ],
        }, ],
    },
};

Sassの構文

Sassの構文紹介の記事って日本語でもかなり落ちているんですが、パパっと観ていった中で一番分かりやすいのが公式のこのページでした。 分かりやすさとは関係ないけど、ページが軽量なのと広告無いのが地味に大きいんですかね。

sass-lang.com

ここでSCSSをトランスパイルした結果を観ることができる

www.sassmeister.com

変数 / ネスト

変数は$var: hogeの形で宣言することができ、利用するときにも$varで利用できる。
HTMLと同じように階層的にスタイルを書くことができる。ネストは深すぎると視認性を損なうため、3階層がまで良いようです。

I suggest you to not go more than 3 levels deep.

ベストプラクティスの記事 にありました。

$primary-color: #333;
$secondary-color: #fff;

nav {
  color: $primary-color;
  background-color: $secondary-color;
  
  li {
    width: 100%;
    
    &.primary-items {
      font-weight: bold;
    }
    
    // 隣接セレクタや兄弟セレクタも&を使って表現できる
    &+#selected {
      cursor: none;
    }
    
    // 疑似要素も然り
    &:hover {
      background-color: #666;
    }
  }
}

ファイルの分割

ソースコードの再利用性や保守性を高めるために、部分的にファイルを分けて、別のscssファイルからincludeすることができます。
そのincludeされる側のファイルをpartialというようにSassでは呼称しており、ファイル名の戦闘にアンダースコアが付きます。例えば、_partial.scssといった形です。そういったファイル名にすることによって、Sassはそのファイルをcssファイルにトランスパイルすべきでないことを知ります。

これらの仕組みは@useルールとともに利用することができ、Modulesと呼称されます。@useルールを使うことで、他のsassファイルをモジュールとしてロードすることができます。つまり、ロードしたファイルの変数や関数、mixinを参照することができます。
@useルールはDartSassにしか今のところ実装がなく、LibSassやRubySassの利用者を従来までの@importを利用する他なさそうです。@importは、下記の記事にあるように、名前空間が同一であったことから多くの問題を引き起こしたがために廃止する流れとなったとあり、DartSassを利用している人は@useを利用するべきのようです。@useの場合は、ファイル名.mixin名などのように利用しますが、@importはグローバル名前空間に共有されるので、簡潔な名前が使いづらい状態でした。

sass-lang.com

@extend

事前に定義したCSSプロパティのセットを複数のセレクタに共有することができるので、DRY原則の遵守に役立てることができる。
関連性のあるグルーピングに適用させるのがポイントで、そうしない場合に、下記のような負債を生み出してしまうので注意。ソースはこのブログextendという文字の通り継承させるものにのみ適用するのが良いということでしょう。ボタンなどが分かりやすい例と思っています。
関連性のないグルーピングに対してDRY原則を適用させたい場合には、引数なしの@mixinを利用するのが良さそうです。

@extendを使ったことで、スタイルシートが倍以上のサイズになってしまったり、ソースの順序がめちゃくちゃになってしまったり、セレクタが4,095個に達してしまったりしたケースをたくさん目にしてきました

f:id:jrywm121:20200518011649p:plain

@mixin

関数に近い機能を持ちます。下記のようにベンダープレフィックスなどが必要になるものに関してはかなり有効のようです。

@mixin transform($property) {
  -webkit-transform: $property;
  -ms-transform: $property;
  transform: $property;
}
.box { @include transform(rotate(30deg)); }

まとめ

@extendのネガティブな面について調べるまで想像することが出来ていませんでした。
保守性の高いコードを書くためにも、命名方法論のBEMなど周辺知識も合わせてしっかり勉強していこうと思います。
とりあえずリファクタですね。プロパティの順番などもある程度基準があるようなので合わせていきたいと思います。

余談ですけど、VSCodeでWEBサーバ簡単に建てられるんですね。Live ServerっていうExtensionであっさり出来すぎて笑っちゃいました。

画像はhtmlとcssのどちらで定義をすべきか

developer.mozilla.org

支援技術は背景画像を解析できません。画像がページの全体的な目的を理解する上で重要な情報を含んでいるのでいる場合は、文書上で意味を記述してください。

MDNのドキュメント内にある、cssbackground-imageプロパティには上記のような記載があります。
アイコンなどを含む画像をページに埋め込む際に、htmlのimgタグで指定するのか?それともcssbackgroundプロパティを利用するのか悩ましいところです。 SEOには詳しくはないですが、ある程度SEOにも絡んでくるのではないでしょうか。

企業によってどのように使い分けているか見てみた

企業を選ぶ基準は特にありませんが、個人的にアクセシビリティSEOを意識していそうだなと思ったところを見ています。

Google

Google SlideやGoogle Keep、コーポレートサイトを見ました。
ロゴ以外は基本的にcssbackgroud-imageプロパティを使っているようです。ただ、アクセシビリティを上げるために、ボタン代わりの画像はdiv要素を割り当てて、rolearia-lebelといったようなAttributeを付与しています。

Mozilla

Firefoxの配布ページとかを見ました。
こちらはばらつきがあり、PC用配布ページはbackgroud-imageプロパティを使っていたが、モバイルアプリ用配布ページはimgタグを使っていた。装飾的に使用されているimgタグalt=""という属性がつけていた。

アクセシビリティの考慮

WAI-ARIAW3C によって定められた仕様で、要素に適用できる追加の意味論を提供する一連の HTML 属性を定義しており、それが欠けているどのような場所でもアクセシビリティを向上させます。

developer.mozilla.org

momdo.github.io

WAI-ARIA role属性

定義済のロールの中から選択し付与することでhtml5のセマンティクスでは表現しきれない(もしくは代替要素を利用しているため表現できない)要素を意味を足すことができます。
ロールは、「抽象」「ウィジェット」「ランドマーク」などのいくつかの種類に分かれている。

aria-label

area-*属性のプロパティのひとつ!
「ラベルテキストが画面上で可視である場合、著者はaria-labelledbyを使用すべきであり、aria-labelを使用すべきでない」とのことなので画面上に表示されていないものに対して、それこそ例えばクリッカブルなアイコンなどに利用するべきと思います。 これで指定したラベルはスクリーンリーダーなどに読み上げられるそうです。

f:id:jrywm121:20200510092016p:plain

まとめ

基本的にはcssbackgroundプロパティを利用する運用で問題なさそうです。意味のある要素であれば、アクセシビリティを考慮したAttributeを付与したdivspanを用意して、その要素に対してcssで画像を付けてあげるというので自分の中では整理がつきました。
imgタグを使った場合に、装飾利用のみの画像であれば、alt属性を空と明示するべきと分かりました。

<div role="button" area-label="修正" class="modify"></div>
<img src="decoration.png" alt>
.modify {
   // url x軸の位置 y軸の位置 / x軸のサイズ y軸のサイズ 繰り返さない
   background: url('...') center center/15px 15px no-repeat;
}

JSの履歴APIを使って同一ページ内での疑似遷移でもブラウザバックできるようにする

タイトルを付けるのが難しかった…未来の自分が見返したときにこれでピンとくるのだろうか?
例えば、下記のようなケースで同一ページでもユーザからするとページ遷移したかのように感じるということがあります。

  • Ajax通信でデータをフェッチし、既存のデータをリプレイスする形で画面に反映させる
  • サマリーページと詳細ページを同一HTML内で返却していて、JSによって表示を切り替えている

このような場合に、何もしなければ、ページ内でのまるで遷移したかのような行動は反映されていないため、「戻ると想定していたページと違う」ことが予想されます。この行動をブラウザの履歴に反映させようというやつです。

JSのHistory APIを使う

実質、下記を見れば終わり!
どうしてMDNのサイトってこんなに見やすいのだろうか?神か?ありがとうございます!

developer.mozilla.org

pushstateで履歴を挿入する

履歴エントリ群はスタックのデータ構造を持っている。history.pushState()を使うことで、そのスタックに履歴エントリを追加することができる。
最初に出した例で言うと、Ajax通信が正常終了したときにそのAjax通信を再現できる情報を詰めて突っ込めば良さそう。キャッシュ可能なデータならば、Ajax通信のレスポンスヘッダにCache-Controlヘッダ付けて、privateかつmax-age: 120にでもしておけば、2分以内にブラウザバックを押したときに再度通信が走ることはないので快適かもしれません。

    let stateObj = {
        page: 2,
    };
    
    let title = `${document.title} - 2` 
    
    /**
     * JSDocの使い方間違っているけど、分かりやすいので…
     * @param {Object} stateObj エントリと一緒に保存できるオブジェクト(空でもOK)
     * @param {string} title 文書のタイトル(Safari 以外のブラウザはこのパラメータを無視)
     * @param {string} URL 絶対パス相対パス問わず
     */
    history.pushState(stateObj, title, "/product?page=2");

popstateをフックする

サマリーページと詳細ページを同一HTML内で返却していて、JSによって表示を切り替えている

最初に挙げたこのケースの場合には、history.pushState()を使わなくとも、history.popStateへのhookとURLの#(ハッシュ)を使えば実現可能。

  • 詳細への遷移の際に、URLの#(ハッシュ)にどの要素を展開しているかを残しておく
  • ユーザがブラウザバックをしたときに発生するイベント popstateをフックして処理を行う
// pureJS
window.onpopstate = function() {
    let currentState = history.state;  // 現在の履歴のstateを読み込む
}

// jQuery
$(window).on('popState', function () {
    if (location.hash != undefined) {
        $(location.hash).css('display', 'block');
        $('#summmary').css('display', 'none');
    }
})

感想

SPAのページ遷移もHistory APIを使って実現しているのかなと思いました。
windowオブジェクトにぶら下がっているオブジェクトを全然把握できていなくて、JSで実現できることは自分の想像以上に多くあるんだろうなとも感じますね。

JSをさらっと学んでみた

ちゃんと書いたことあるのが、GoとJava(とC#)だけの自分がJavaScriptを速習しました。というのも、業務で急に必要になってしまったからです。
以前少し触ったことがあるものの中途半端な状態で触るのも無責任と思い、下記を読んで、気をつけようと思った点をまとめてみました。
クライアントサイドJS前提ですが、FetchAPIとかクライアントサイドに特化した部分はここではメモしていない。ES5をメインで使わなくてはいけないシチュエーションですが、今後ES6を使っていけると信じてES6も併記していきます。

gihyo.jp

google.github.io

Variables

変数はデフォルトでundifinedという値を割り当たる

  • ある変数が宣言済みであるものの値を与えられていない場合
  • 未定義のプロパティを参照しようとした場合
  • 関数で値が返されなかった場合

変数の命名ルールはJavaと同じっぽい

  • 定数: SNAKE_CASE
  • 変数: camelCase
  • クラス: PascalCase

[ES6] ローカル変数は基本constを使って、再代入をする場合のみletを使う!varは癖がるので使わない!

  • varは宣言のスコープがブロックでなく関数
  • 変数の巻き上げによって、関数の最初に宣言されるような挙動となる
    • 宣言される前に既に変数は割り当てられているが、代入は宣言された行までされない
    • そのため、YAGNI原則に背き関数の一番最初に使用する変数を全て宣言する書き方がされる
    • 同じ変数名で再宣言できてしまう(Rustのshadowingに似ているというと聞こえはいい)
  • 「関数内でグローバル変数を書き換える」ような用途を除いては、原則としてvar命令を省略すべきではない。グローバル変数を宣言する場合には省略してもしなくても挙動は同じだが、混乱を防ぐためにvarで宣言すべき
// 定数は大文字+スネークケース
var TRAJA_LEADER_NAME = "chaka";

// クラス名はPascalCase
var BoyBand = function(leader) {
    this.leader = leader;
    this.greet = function() {
        // 変数の巻き上げによって、関数の最初に宣言されるような挙動となる(が変数の代入は行われないため「undefined」となる)
        console.log(greeting) // undefined
        var greeting = "Good morning! ";
        let selfIntroduction = "We are TravisJapan!"
        {
            var greeting = "Good Evening! ";
            const selfIntroduction = "We are SixTONES!";
        }
        // varのスコープはブロックではなく、関数なので後から宣言した方が優先される
        // let,const はブロックスコープ
        console.log(greeting + selfIntroduction);
    }
}

var leader;
console.log(leader); // 初期化していない変数のため「undefined」となる
var leader = TRAJA_LEADER_NAME;  // なんと同じ変数で再宣言できてしまう

// 変数名はCamelCase
var travisJapan = new BoyBand(leader); 
console.log(travisJapan.leader); // 「chaka」
console.log(travisJapan.KaraokeMovie); // 未定義のプロパティのため「undefined」となる

var result = travisJapan.greet();  // Good Evening! We are TravisJapan!
console.log(result); // 戻り値のない関数のため「undefined」となる

データ型

型名 データの持ち方 備考
number プリミティブ 64ビットの浮動小数点数 数値型はこの他に任意の精度を持つ整数型のBigIntが存在する(※1)
string プリミティブ 文字の集合、内部では0 個以上の 16 bit 符号なし整数の列として扱われる GoやJavaと違い参照ではない(※2)が、必要に応じて暗黙的にboxingされてStringオブジェクトとなる(※3)
文字列リテラルはダブルクォートよりもシングルクォートを優先して使う(※4)
[ES6] 文字列や複数行になるときや複雑な結合になる場合には、テンプレート文字列を利用する
boolean プリミティブ true/falseの真偽値 暗黙的なfalseを取る値がある
・ 空文字列
・ 数値の0, NaN
・ null, undefined
特殊値 プリミティブ null/undefined nullは明示的に空を表すときに使用する
array 参照 各要素に対してインデックス番号でアクセス可能なデータの集合 要素の追加/削除/反復などのメソッドを持つ
[ES6] 分割代入が便利(※5)
object 参照 各要素に対してkeyとなる名前でアクセス可能なデータ集合 JSにおいて連想配列=オブジェクト
[ES6] オブジェクト内の個々のデータをプロパティ、中でも関数が格納されたプロパティはメソッドと呼称する
function 参照 一連の処理をまとめたもの 宣言ではfunction命令よりもアロー関数が好ましいとされる(※6)

※1: JavaScript のデータ型とデータ構造 - JavaScript | MDN
※2: Goはstructだが実質byteスライスのwrapperという認識です(https://go101.org/article/string.html
※3: String - JavaScript | MDN
※4: https://google.github.io/styleguide/jsguide.html#features-strings-use-single-quotes
※5: 分割代入 - JavaScript | MDN
※6: https://google.github.io/styleguide/jsguide.html#features-functions-arrow-functions

// 初期化方法
const PI = 3.14;
console.log(typeof PI); // number
let charset = 'utf-8';
console.log(typeof charset); // string
let finished = false;
console.log(typeof finished); // boolean
const bucket = null;
console.log(typeof bucket); // object
let newSongOfSixtones;
console.log(typeof newSongOfSixtones); // undefined
const signalColor = ['red', 'yellow', 'green'];
console.log(signalColor instanceof Array);  // true
const Member = function(){};
member = new Member();
console.log(typeof member);   // object
const bark = () => { console.log("bowbow"); };
console.log(typeof bark);   // function

// [ES6]分割代入を使った典型的なswap
let x=1;
let y=2;
[x,y]=[y,x];
// [ES6]テンプレート文字列で直感的に変数を埋め込める
console.log(`x: ${x}, y: ${y}`); // x: 2, y: 1

control structures

条件分岐

等価のチェックには、===または!==を使用する、ただnullundefinedの両方をキャッチしたいときのみ== undefinedとする

  • ==はデータ型が違う場合にもJS側が変換して無理やり比較するため想定外の結果が得られることがある
  • Javaと違いObject.equalメソッドなど無いため、オブジェクトは単純にアドレスの比較になる
let n = null;
// nullとundefinedどちらも引っ掛けたいときのみ ==,!= を利用する 
if (n == undefined) {
    console.log('Null or Undefined'); // 出力される
    n = n === undefined ? 'undefined' : 'null'; // 3項演算子アリ!個人的には嬉しい!
}
// 基本は ===, !== で比較
if (n === 'null')  console.log('null'); // 1行であれば中括弧を省ける

// breakの後の1行開けると見通しが良い
const signal = 'red';
switch(signal) {
    case 'green':
        console.log('Go!');
        break;

    case 'yellow':
        console.log('Pay Attention!');
        break;
        
    default:
        console.log('Stop');
}

ループ

ループの制御構文としては、while, do ~ while, forに加えて、連想配列を順に処理するfor...in、配列やArrayなどEumerableなオブジェクトを順に処理するfor...ofがある
また、Go, Javaと同じようにラベル付きbreak/continueがあるのでネストが深くなったときには利用しても良さそう

// for, do while, whileの構文はJavaとほとんど変わらない
for (var i=0;i<10;i++) { }
do {} while (i < 5)
markLoop:
while (true) {
    while(true) {
        console.log(`i: ${i}`);  // iはvarで宣言されているのでアクセス出来てしまう(ES6以降はlet使う)
        if (++i > 11) {
            // ラベル付きBreakが可能で、外側のループに付与されたラベルをもとにbreakするので
            // 外側ループの無限ループに巻き込まれない
            break markLoop;
        }
    }
}
/** 上記の処理の出力
i: 10
i: 11 
**/

// for...inはオブジェクトのプロパティに対して反復する
var ExamScore = function(japanese, english) {
    this.japanese = japanese;
    this.english = english;
    this.getSum = function() {
        return this.japanese + this.english;
    }
}
var secondRegularExam = new ExamScore(87, 64);
for (var key in secondRegularExam) {
    console.log(`${key}: ${secondRegularExam[key]}`);
}
/** 上記の処理の出力
japanese: 87
english: 64
getSum: function() {
        return this.japanese + this.english;
    }
**/

// array, Map(ES6以降)などに関してはfor...ofを使用する
// for...inを使用するとユーザ定義の関数なども合わせて列挙されてしまうため
var fruits = ['banana', 'apple', 'melon'];
for (var e of fruits) {
    console.log(e);
}
/** 上記の処理の出力
banana
apple
melon
**/

例外処理

  • 例外処理はJavaと同じく、try...catch~finally
  • throwで意図的に例外発生させることができるが、Javaと違いExceptionオブジェクトを継承したものでなく、ErrorオブジェクトもしくはXxxxxErrorオブジェクトをthrowすることになる
    • 文字列等いろんな型をthrowで投げることが出来てしまう
const price = -1;
try {
    if (price < 0) {
        // 組み込みのError型がいくつか存在する
        throw new RangeError('price must be positive')
    }
} catch(e) {
    console.log(e);
} finally {
    // コネクションのクローズ処理など
}

オブジェクト指向プログラミング

主要な組み込みオブジェクト

// Stringオブジェクト
const str = '夢のHollywood'.substring(2, 11); // 内部ではboxing -> メソッド呼び出し -> unboxingが行われている?
console.log(`Type: ${typeof str}  Value: ${str}`); // Type: string  Value: Hollywood

// Numberオブジェクト
console.log(4.66665.toPrecision(4)); // 4.667

// Mapオブジェクト
const map = new Map([['hokuto', 'black'], ['kyomo', 'pink'], ['shintaro', 'green']]);
map.set('Jessy', 'red');
console.log(map.size);  // 4

// Dateオブジェクト
const today = new Date();
console.log(today.getFullYear()); // 2020

// RegExpオブジェクト
var re = new RegExp('[0-9]{4}-[a-zA-Z]+'); // オブジェクト生成時に評価
var re = /([0-9]{4})-([a-zA-Z]+)/; // 正規表現が不変のときはリテラル
var text = '2020-olynpic';
var newtext = text.replace(re, '$2_$1'); // olynpic_2020
console.log(newtext);
  • Object

    • すべてのオブジェクトの雛形
    • preventExtensions/seal/freezeといったメソッドを利用することで、Immutableにできる
  • Globalオブジェクト

    • グローバル変数やグローバル関数を管理するために、JavaScriptが自動的に生成する便宜的なオブジェクト
    • encodeURI()のようによく使われる機能などをグローバルオブジェクトに紐づく関数としてJSがデフォルトで提供している
    • ブラウザにおいてはwindowオブジェクトがグローバルオブジェクトにあたる、そのためwindow.location.hrefなどはlocation.hrefでアクセスできるし、グローバル空間において宣言したvar nwindow.nでアクセスできる
  • JSON.stringify(obj)でobjectをJSONとして吐くことができる

関数

  • 関数宣言
    • functionキーワードで宣言するよりも簡潔かつthisを束縛しないことからarrow関数で書くほうがより好ましい(特にネストした場合には)
      • アロー関数でない場合に、thisは関数の呼び出し方法によって変わる
        • コンストラクタであれば新しいオブジェクト
        • 「オブジェクトのメソッド」として呼び出された関数ではそのときのオブジェクト
        • 通常の関数呼び出しであればグローバルオブジェクト(strictのときにはundefined
      • アロー関数であれば、レキシカルスコープの this 値を使うため、通常の変数検索ルールに従う -> 同じスコープ内になければ外側のスコープを見に行く
// arrow関数
const moduleLocalFunc = (numParam, strParam) => numParam + Number(strParam);

// function命令
function moduleLocalFunction(numParam, strParam) {
  return numParam + Number(strParam);
}
  • ES2015より前はJavaScriptは引数の数をチェックしない
    • argumentsオブジェクトというのが関数配下でのみ使える特別な変数になっており、それを使って引数のチェックなどを行う
    • 可変長引数もargumentsオブジェクトを利用する
  • ES2015以降はデフォルト引数や可変長引数、名前付き引数が言語の機能として提供されるようになった
    • functiongetTriangle(base=1,height=1){...}
    • functionsum(...nums){}
    • getTriangle({base:5,height:4}))
  • ES2015以降は分割代入を利用してGoのように多値返却も可能に
functiongetMaxMin(...nums){
   return[Math.max(...nums),Math.min(...nums)];
}
let[max,min]=getMaxMin(10,35,5,78,0);
  • ES2015以降で、タグ付きテンプレート文字列を使えば、文字列を加工する関数を用意しておいて文字列定義と一緒に利用することが可能
  • Callオブジェクトとは、「関数呼び出しの都度、内部的に自動生成されるオブジェクト」で関数内で定義されたローカル変数を管理するための便宜的なオブジェクトであり、argumentsもこれで管理されている
    • 内部のCallオブジェクト、外部のCallオブジェクト、グローバルオブジェクトという順でスコープチェーンがある場合、内部から順番にオブジェクトに紐づくプロパティを参照していき、変数を解決する
  • クロージャも機能として提供されており、自分が定義されたスコープの変数をキャプチャすることができる

関数内のどこにでもvar文を使用して変数を宣言することができる。 そして、これらの変数は関数内のいかなる場所で宣言されたとしても、 その関数の先頭で宣言されたのと同じように動作します。

ES2015より前のオブジェクト指向

プロトタイプベースのオブジェクト指向
Functionにクラスとしての役割を与えている。
プロパティの定義は、自分自身を指し示すthisキーワードを使って変数を宣言する。
インスタンス化した後に動的にメソッドを追加することができるが、個人的には混乱を招きそうなのでsealメソッドを利用したほうが良さそう。

function Car(make, model) {
  this.make = make;  // プロパティの定義
  this.model = model;
  this.getName()=function(){
    return this.make + ' ' + this.model
  }  // メソッドの宣言
  Object.seal(this);  // 後からプロパティと削除/追加されないようにする
}

var car1 = new Car('Eagle', 'Talon TSi');

↓ 個人的な所感

インスタンスを生成するたびに、それぞれのインスタンスのためにメモリを確保する -> 無駄
prototypeプロパティに格納されたメンバーは、インスタンス化された先のオブジェクトに引き継がれる

JSで保守性を上げる

JSDoc

メソッド引数などに型の宣言が無い以上、ドキュメンテーションによる判断が重要になってくる。JSDocは下記のようにVSCodeIntelliJなどのIDEでサポートされているもので非常に有用。
また、node.jsで動くjsdocというツールでドキュメント生成することも可能。アノテーション一覧はこれ

f:id:jrywm121:20200510005657p:plain

namespace

いわゆるJavaのpackageのような仕組みが無いため、空のオブジェクトを利用して擬似的に名前空間を作成します。階層を持つことがより望ましいため、下記のような関数を用意すると良いようです。
共通利用する関数などはnamespaceを使うべき。

function namespace(ns) {
    let names = ns.split('.');
    let parent = window; // 最上位のwindowオブジェクト
    
// 'parent[v] || {}' とすることによって、parent[v]が定義されていないときのみ新たなオブジェクトを生成するという形
    names.forEach(v => {
        parent[v] = parent[v] || {};
        parent = parent[v];
    });
    return parent;
}

var common = namespace('com.example.app.common');
common.Util = function() { ... };

ファイル分割

以前までだと、別のファイルの関数を読み込むためには、html内でのスクリプトファイルの読み込みの順番を気をつける必要がありました。呼び出される側のファイルを先に定義しておかなければいけないためです。
ですが、ES2015以降ではモジュールという機能が登場したおかげでその状況は変わったようです。
本格入門では「執筆時点でモジュールをサポートしているブラウザーはありません」と記載がありますが、現在ではIE以外の主要なブラウザではサポートされているよう。
詳しい情報はここ参照。

private

JSではプライベートなメンバを定義する機能は無いが、クロージャを利用することで擬似的に再現できる

var Member = function() {
    // プライベートメンバはvarで定義する
    var _firstName;
    var _lastName;

    var _checkArgs = function(val) {
        return val.length > 0;
    }

    this.setFirstName = function(firstName) {
        if (_checkArgs(firstName)) {
            _firstName = firstName
        }
    }
    // 以下省略
}

感想

如何にグローバル空間を汚さないか、型の制約が無い状態で期待通りの動きをさせるかが肝なのだなと思った。
JS特有の表現で真偽値分かれるのは(例えば0であれば偽)、他の言語畑から来た人からすると分かりづらくてしんどいですね。
以前、軽くAngularを触ったときにはTypeScriptだったので、pureJSの良し悪しが今回わかってよかった。