なになれ

IT系のことを記録していきます。

MEAN StackをKubernetesで動かす(その2)

前回はHTTPでアクセスするところまでを行いました。
hi1280.hatenablog.com

今回はHTTPS対応を行います。

MEAN Stackのプログラム一式はこちら
github.com

実運用に耐えうることを想定して、以下の内容を含めています。
MongoDBの内容が前回までで、今回はHTTPS対応の話になります。

  • MongoDBは可用性を高めるためにReplica Setで構成する
  • 外部公開を考慮して、HTTPS対応を行う

cert-managerというKubernetes上で自動的にSSL証明書を管理してくれるパッケージを使います。
github.com なお、cert-managerはまだ安定版ではないようなので、今回の内容が無効になる可能性があります。

事前のインストール

cert-managerをインストールするためにHelmというKubernetesのパッケージ管理ツールが必要です。
各環境に応じたHelmのクライアント環境をインストールします。
docs.helm.sh

KubernetesクラスタにHelmとcert-managerをインストールします。

$ kubectl create serviceaccount -n kube-system tiller

$ kubectl create clusterrolebinding tiller-binding \
    --clusterrole=cluster-admin \
    --serviceaccount kube-system:tiller

$ helm init --service-account tiller

$ helm repo update

$ helm install --name cert-manager --namespace kube-system stable/cert-manager

cert-managerにおけるLet's Encryptのセットアップ

自分のemailアドレスを環境変数にセットします。

$ export EMAIL=xxx@yyy.com

証明書の発行者をLet's Encryptにしてリソースを作成します。

$ curl -sSL https://rawgit.com/ahmetb/gke-letsencrypt/master/yaml/letsencrypt-issuer.yaml | \
    sed -e "s/email: ''/email: $EMAIL/g" | \
    kubectl apply -f-

先ほどのemailアドレスをセットしています。

証明書を取得する

これからの手順を行うにあたり、前回割り当てられたIngressIPアドレスドメイン名を登録しておく必要があります。
事前にドメイン登録業者などで設定しておきます。

ドメイン名が有効になったら、証明書を取得します。

certificate.yml

apiVersion: certmanager.k8s.io/v1alpha1
kind: Certificate
metadata:
  name: webapp-tls
  namespace: default
spec:
  secretName: webapp-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  commonName: mean-k8s-example.hi1280.site
  dnsNames:
  - mean-k8s-example.hi1280.site
  acme:
    config:
    - http01:
        ingress: webapp
      domains:
      - mean-k8s-example.hi1280.site

issuerRefで証明書の発行者のリソースを指定しています。
ドメイン名はcommonNamednsNamesdomainsに指定します。
ingressで前回作成したIngressのリソース名を指定します。

$ kubectl apply -f certificate.yml

ここで時間がかかりますので、5分から10分ほど待ちます。

webapp-tlsというSecretリソースが表示されれば、正常に完了しています。

$ kubectl get secrets

describeコマンドでリソース作成の進行状況を確認することができます。

$ kubectl describe -f certificate.yaml

HTTPSに対応する

証明書を使うようにIngressを更新します。

ingress-tls.yml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: webapp
  labels:
    app: webapp
spec:
  backend:
    serviceName: svc1
    servicePort: 80
  tls:
  - secretName: webapp-tls
    hosts:
    - mean-k8s-example.hi1280.site

ドメイン名やSecret名をtls追記しています。

$ kubectl apply -f ingress-tls.yml

10分ほどでHTTPSでアクセスできるようになります。

まとめ

実運用に耐えうるコンテナ環境ができることをKubernetesを利用して確認できました。
今回はGKEを利用したので楽ができました。
逆に自前運用だと大変そうだと思いました。

参考にした資料

github.com

MEAN StackをKubernetesで動かす(その1)

前回はMEAN StackをDocker Composeで動かしてみました。
hi1280.hatenablog.com

今回はMEAN StackをKubernetesで動かします。
Dockerに対応したので、Kubernetesでも動くようになっているはずです。試してみます。

なお、今回のKubernetesの環境はGKE(Google Kubernetes Engine)を使用します。
手軽にKubernetes環境が用意できるので、試しに使ってみるには良いと思います。
無料のトライアル期間もあります。

MEAN Stackのプログラム一式はこちら
github.com

Docker Composeの時と似たような構成でAngularとExpressとMongoDBを動かします。
実運用に耐えうることを想定して、以下の内容を含めます。

  • MongoDBは可用性を高めるためにReplica Setで構成する
  • 外部公開を考慮して、HTTPS対応を行う

HTTPS対応の話は次回に行います。今回はHTTPでアクセスするところまでです。

事前準備

GKEを利用するために、GCP(Google Cloud Platform)用のコマンドラインツール(gcloud)をインストールします。
Quickstarts  |  Cloud SDK  |  Google Cloud

kubectlコマンドをインストールします。
Quickstart  |  Kubernetes Engine Documentation  |  Google Cloud

gcloudコマンドでKubernetesクラスタを構築します。

$ gcloud container clusters create mean-example

MongoDBのセットアップ

MongoDBを作成する前にデータを保存するディスクを準備する必要があります。

ディスクの設定

何のディスクを利用するのかを決めるためにStorageClassを作成します。
ここでは、GCE(Google Compute Engine)のディスクを利用するように設定します。

gce-storageclass.yml

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: slow
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-standard
$ kubectl apply -f gce-storageclass.yml

MongoDBの作成

Replica Setで認証を行うためにkeyfileを用意します。
KubernetesのSecretに登録します。

$ TMPFILE=$(mktemp)
$ /usr/bin/openssl rand -base64 741 > $TMPFILE
$ kubectl create secret generic shared-bootstrap-data --from-file=internal-auth-mongodb-keyfile=$TMPFILE
$ rm $TMPFILE

Replica Setにおける認証の参考情報
Internal Authentication — MongoDB Manual

MongoDB用のStatefulSetとServiceを作成します。

mongodb-service.yml

apiVersion: v1
kind: Service
metadata:
  name: mongodb-service
  labels:
    name: mongo
spec:
  ports:
  - port: 27017
    targetPort: 27017
  clusterIP: None
  selector:
    role: mongo
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongod
spec:
  selector:
    matchLabels:
      role: mongo
  serviceName: mongodb-service
  replicas: 3
  template:
    metadata:
      labels:
        role: mongo
        environment: test
        replicaset: MainRepSet
    spec:
      terminationGracePeriodSeconds: 10
      volumes:
        - name: secrets-volume
          secret:
            secretName: shared-bootstrap-data
            defaultMode: 256
      containers:
        - name: mongod-container
          image: mongo:3.4.16-jessie
          command:
            - "mongod"
            - "--bind_ip"
            - "0.0.0.0"
            - "--replSet"
            - "MainRepSet"
            - "--auth"
            - "--clusterAuthMode"
            - "keyFile"
            - "--keyFile"
            - "/etc/secrets-volume/internal-auth-mongodb-keyfile"
            - "--setParameter"
            - "authenticationMechanisms=SCRAM-SHA-1"
          ports:
            - containerPort: 27017
          volumeMounts:
            - name: secrets-volume
              readOnly: true
              mountPath: /etc/secrets-volume
            - name: mongodb-persistent-storage-claim
              mountPath: /data/db
  volumeClaimTemplates:
  - metadata:
      name: mongodb-persistent-storage-claim
      annotations:
        volume.beta.kubernetes.io/storage-class: "slow"
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 10Gi

ServiceはHeadless Serviceで作成します。
この後にReplica Setの設定を行う際に、PodのIPアドレスを知る必要があるためです。

StatefulSetではMongoDBコンテナが起動する3つのPodを作成します。
3つのPodがReplica Setで利用されます。
mongodコマンドで、事前に作成したSecretを利用して認証を有効にします。 volumeClaimTemplatesで先ほどのStorageClassを指定しています。
Pod毎に10Gのディスクが設定されます。

$ kubectl apply -f mongodb-service.yml

MongoDBの設定

PrimaryになるMongoDBに接続します。

$ kubectl exec -it mongod-0 -c mongod-container bash
$ mongo

Replica Setを設定します。

> rs.initiate({_id: "MainRepSet", version: 1, members: [
       { _id: 0, host : "mongod-0.mongodb-service:27017" },
       { _id: 1, host : "mongod-1.mongodb-service:27017" },
       { _id: 2, host : "mongod-2.mongodb-service:27017" }
  ]})

ユーザを作成します。

> db.getSiblingDB("admin").createUser({
      user : "xxx",
      pwd  : "xxx",
      roles: [ { role: "root", db: "admin" } ]
  })
> use admin
> db.auth("xxx","xxx")
> db.getSiblingDB("my-heroes").createUser({
      user : "xxx",
      pwd : "xxx",
      roles: [{role:"readWrite", db: "my-heroes"}]
  })

ユーザ、パスワードは適宜設定する。

Angular+Expressの作成

Expressを作成します。

webapp-backend-service.yml

apiVersion: v1
kind: Service
metadata:
  name: webapp-backend-service
  labels:
    name: webapp-backend
spec:
  type: ClusterIP
  ports:
    - protocol: 'TCP'
      port: 3000
      targetPort: 3000
  selector:
    app: webapp-backend
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: webapp-backend
  template:
    metadata:
      labels:
        app: webapp-backend
    spec:
      containers:
      - name: webapp-backend
        image: asia.gcr.io/arched-photon-204013/mean-example_backend:latest
        ports:
        - containerPort: 3000
        env:
        - name: PORT
          value: '3000'
        - name: MONGODB_URI
          value: 'mongodb://xxx:xxx@mongod-0.mongodb-service:27017,mongod-1.mongodb-service:27017,mongod-2.mongodb-service:27017/my-heroes?replicaSet=MainRepSet'

コンテナレジストリにはGCR(Google Container Registry)を利用しています。
前回のExpress実行用のDockerfileから作成したイメージです。

Replica SetのMongoDBに接続するため、3つのMongoDBをホスト名で指定しています。

$ kubectl apply -f webapp-backend-service.yml

Angularを作成します。

webapp-frontend-service.yml

apiVersion: v1
kind: Service
metadata:
  name: webapp-frontend-service
  labels:
    app: webapp-frontend
spec:
  type: NodePort
  ports:
    - name: "http-port"
      protocol: "TCP"
      port: 80
      targetPort: 80
  selector:
    app: webapp-frontend
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: webapp-frontend
  template:
    metadata:
      labels:
        app: webapp-frontend
    spec:
      containers:
      - name: webapp-frontend
        image: asia.gcr.io/arched-photon-204013/mean-example_frontend:latest
        ports:
        - containerPort: 80
        env:
        - name: APP_HOST
          value: 'webapp-backend-service'
        - name: APP_PORT
          value: '3000'
        command: ['/bin/sh']
        args: ['-c', "envsubst '$$APP_HOST$$APP_PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"]

HTTPSに対応するためにIngressを利用することになります。Ingressを利用するためには、事前にNodePortのServiceを作成しておく必要があります。

コンテナイメージに関しては、前回のAngularアプリ実行用のDockerfileから作成したイメージです。

$ kubectl apply -f webapp-frontend-service.yml

外部からの疎通が可能なネットワークの設定

Ingressを作成します。

ingress.yml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: webapp-service
  labels:
    app: webapp
spec:
  backend:
    serviceName: webapp-frontend-service
    servicePort: 80

backendでは先ほど作成したNodePortのServiceを指定しています。

$ kubectl apply -f ingress.yml

Ingressを作成することで、GCLB(Google Cloud Load Balancer)が設定されます。

$ kubectl get ingress

NAME             HOSTS     ADDRESS           PORTS     AGE
webapp-service   *         xxx.xxx.xxx.xxx   80        5m

5分ほどで静的IPアドレスが割り当てられて、アクセスできるようになります。

HTTPS対応の話は次回に。

参考にした資料

Kubernetesの基礎情報 thinkit.co.jp

MongoDBのKubernetes対応 pauldone.blogspot.com

MEAN StackをDocker Composeで動かす

前回はMEAN StackをHeroku上で動かしてみました。
hi1280.hatenablog.com

今回はMEAN Stackアプリに手を加えて、Dockerに対応させます。
Dockerにすれば、他の環境への移行も楽になるのではと思います。
Docker Composeを使って、nginx+Node.js+MongoDBの環境でMEAN Stackアプリを動かします。

プログラム一式はこちら
github.com

Docker Composeを使って、以下の3つのコンテナで構成します。

  • nginxでAngularを動かす(フロントエンドアプリ)
  • Node.jsでExpressを動かす(バックエンドアプリ)
  • MongoDBを動かす(DB)

Angularビルド環境用のDockerfile

AngularビルドのためにAngular CLI環境のDockerfileを作ります。

Dockerfile

FROM node:8.11.3-alpine
LABEL authors="hi1280"
RUN npm install -g @angular/cli@6.0.8
RUN npm cache clean --force

Node.jsの環境にAngular CLIをインストールしているだけというシンプルなモノです。

Angularアプリ実行用のDockerfile

Angularをビルドして、それをそのままDockerコンテナに含めると容量がかなり大きくなってしまいます。
そのため、Angularビルドとnginx上でのAngularアプリの実行を分けます。
これはDockerのマルチステージビルドの機能を使えば、実現できます。

Use multi-stage builds | Docker Documentation

マルチステージビルドを用いたDockerfileを作ります。

Dockerfile

# Angular Build
FROM hi1280/angular-cli:latest as build-stage
WORKDIR /usr/src/app
COPY ["package.json", "package-lock.json*", "./"]
RUN npm install --production --silent
COPY . .
RUN npm run build
# nginx
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build-stage /usr/src/app/dist/mean-example  /usr/share/nginx/html
COPY ./nginx /etc/nginx/conf.d
CMD ["nginx", "-g", "daemon off;"]

COPY --from=build-stageでビルド後の成果物のみをコピーしています。
これにより、最小限のファイルサイズになります。
AngularでビルドしたHTMLリソースをnginxで動かすDockerコンテナになります。

Express実行用のDockerfile

Expressを動かすDockerfileを作ります。

Dockerfile

FROM node:8.11.3-alpine
WORKDIR /usr/src/app
COPY ["server.js", "/src/server/package.json", "/src/server/package-lock.json", "./"]
COPY ["/src/server", "./src/server"]
RUN npm install --production --silent
EXPOSE 3000
ENV NODE_ENV production
CMD [ "node", "server.js" ]

Node.jsの環境を公式のイメージから取得して、Expressを動かすだけのDockerコンテナになります。

Docker Composeで各コンテナを連携

これまでに用意した各Dockerコンテナを連携します。

docker-compose.yml

version: '2.1'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile-frontend
    ports:
      - 80:80
    environment:
      - APP_HOST=backend
      - APP_PORT=3000
    command: /bin/sh -c "envsubst '$$APP_HOST$$APP_PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"
    depends_on:
      - backend
  backend:
    build:
      context: .
      dockerfile: Dockerfile-backend
    ports:
      - 3000:3000
    depends_on:
      - db
    environment: 
      - MONGODB_URI=mongodb://db:27017/my-heroes
  db:
    image: mongo:3.4.16-jessie
    restart: always
    volumes: 
      - /data/db:/data/db

URLにサービス名を指定することで、各サービスにアクセスすることができます。
mongodb://db:27017/my-heroesといった形です。
depends_onを指定することで、サービスを立ち上げるために事前に起動が必要なサービスを指定できます。
これにより、サービスを起動する順番を制御することができます。

Docker Composeにおけるnginxの設定変更

環境変数を用いて動的にnginxの設定を変更しています。
nginxの公式Dockerイメージにそのやり方が記載されています。(Using environment variables in nginx configurationという箇所です)
https://hub.docker.com/_/nginx/
こちらも参考にしました。
Docker上のNginxのconfに環境変数(env)を渡すたったひとつの全く優れてない方法(修正:+優れている方法)

default.conf.template

server {
    listen       80;
    server_name  localhost;

    location /api/ {
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_pass http://${APP_HOST}:${APP_PORT}/api/;
    }

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

}

${APP_HOST}と${APP_PORT}が環境変数で定義した値に変わります。
Expressのサービス名にアクセスする設定になります。

まとめ

公式のDockerイメージを活用すれば、プログラムをコピーして、起動コマンドを実行するといった形で、Dockerコンテナ化できるので難しいことはあまりないかなと思います。
ハマったら、コンテナのシェルに入ってデバッグするといったことができると良いです。

MEAN StackをHeroku上で動かす

MEAN Stackを運用するサービスを検討していましたが、Herokuが良いだろうという結論になり、実際に使ってみました。
Herokuを選んだ決め手は以下のとおりです。

  • Node.jsに対応している
  • MongoDBがアドオンで簡単に使える

Herokuのドキュメントを参考にして、自分なりにやってみました。
Create a Web App and RESTful API Server Using the MEAN Stack | Heroku Dev Center

MEAN Stackの構成

MEAN Stackのアプリを用意しました。
github.com Node.jsでExpressのAPIサーバとAngularで作成したフロントのコンテンツの両方を動かす形です。
これをHerokuでそのまま動かそうという算段です。

プログラムの参考元はこちら github.com

Herokuへのデプロイ

Heroku CLIをインストールします。
The Heroku CLI | Heroku Dev Center

Herokuのアプリ環境を作成します。

$ heroku create
...
Creating app... done, ⬢ secret-scrubland-78144
https://secret-scrubland-78144.herokuapp.com/ | https://git.heroku.com/secret-scrubland-78144.git

gitの設定にherokuというリポジトリが追加されています。

$ git config --list
...
remote.heroku.url=https://git.heroku.com/secret-scrubland-78144.git
remote.heroku.fetch=+refs/heads/*:refs/remotes/heroku/*

デプロイを行うには、このリポジトリにPushします。

$ git push heroku master

MongoDBの追加

HerokuにMongoDBを作成するには、mLabのアドオンを追加します。

$ heroku addons:create mongolab

DBの接続情報はMONGODB_URIという環境変数で取得できます。
今回のプログラムでは既にこの環境変数を使用して、MongoDBに接続するようになっています。

Heroku環境にアクセス

$ heroku open

こちらが今回作成したデモ環境です。
https://secret-scrubland-78144.herokuapp.com/

まとめ

Herokuを使えばMEAN Stackの運用環境を簡単に作成できることが分かりました。

PWA BuilderでPWA化してみてPWAを学ぶ

PWA BuilderというPWA化するためのToolを試してみた記録です。
ざっくりとPWAとは何なのかが分かる内容になっていると思います。

今回は以下のサンプルを利用しています。
Freelancer - One Page Theme - Start Bootstrap

PWA化したデモサイトはこちらです。
Freelancer - Start Bootstrap Theme

PWAとは

PWAはProgressive Web Appsのことです。
PWA Builderの説明によると、以下の内容を含んだものがPWAと言えると思います。

  • Web App Manifestを使用してモバイル端末等からインストールおよび起動されたときの見た目や動作を制御している
  • Service Workerを使用してオフライン対応がされている

マニフェストファイルを作成する

PWA Builderの手順に沿って説明します。
まずはGenerate Manifestでの手順です。
PWA Builderを使うと以下のマニフェストファイルが作成されます。

manifest.json

{
    "dir": "ltr",
    "lang": "Japanese",
    "name": "Freelancer - Start Bootstrap Theme",
    "scope": "/",
    "display": "standalone",
    "start_url": "https://arched-photon-204013.firebaseapp.com/",
    "short_name": "Freelancer",
    "theme_color": "transparent",
    "description": "",
    "orientation": "any",
    "background_color": "transparent",
    "related_applications": [],
    "prefer_related_applications": false,
    "icons": [
        {
            "src": "img/d2248ed4-ab6f-84da-fe68-5962b432d4ac.webPlatform.png",
            "sizes": "48x48",
            "type": "image/png"
        },
        {
            "src": "img/b1d7d8f5-1b9b-a7e9-e644-ba9db81e4d93.webPlatform.png",
            "sizes": "1240x600",
            "type": "image/png"
        },
        {
            "src": "img/4f8afd0d-e3e4-1f11-4f22-3bd7ebfb0c61.webPlatform.png",
            "sizes": "300x300",
            "type": "image/png"
        },
        {
            "src": "img/9c2802bf-a937-d809-fd3a-52f1059a43d8.webPlatform.png",
            "sizes": "150x150",
            "type": "image/png"
        },
        {
            "src": "img/fe805efb-f87e-ff12-eb44-86cc76225d06.webPlatform.png",
            "sizes": "88x88",
            "type": "image/png"
        },
        {
            "src": "img/bd3ec8f1-9ef0-de1f-3d55-093be35a1b5b.webPlatform.png",
            "sizes": "24x24",
            "type": "image/png"
        },
        {
            "src": "img/335ca134-e557-a127-dd7a-f1af5ee35285.webPlatform.png",
            "sizes": "50x50",
            "type": "image/png"
        },
        {
            "src": "img/e19531de-b9a8-0cf7-cc34-0d91b68a0339.webPlatform.png",
            "sizes": "620x300",
            "type": "image/png"
        },
        {
            "src": "img/01b4b12b-d6f8-5e7c-897b-642ae94809b2.webPlatform.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "img/a220fe60-f154-a4b0-2645-7d962bdffd78.webPlatform.png",
            "sizes": "144x144",
            "type": "image/png"
        },
        {
            "src": "img/f699c6fb-cd5b-58a0-6e9a-352f1520876c.webPlatform.png",
            "sizes": "96x96",
            "type": "image/png"
        },
        {
            "src": "img/809fd415-acc1-e2b3-39fb-70e2e867e0e9.webPlatform.png",
            "sizes": "72x72",
            "type": "image/png"
        },
        {
            "src": "img/233576f2-1bb0-e4cd-c0a3-bd0e4605f3c9.webPlatform.png",
            "sizes": "36x36",
            "type": "image/png"
        },
        {
            "src": "img/c1007a03-b2e6-e063-b1a6-e809c502ee0d.webPlatform.png",
            "sizes": "1024x1024",
            "type": "image/png"
        },
        {
            "src": "img/c84d6d5e-051e-1555-dca8-c80c1c2a3181.webPlatform.png",
            "sizes": "180x180",
            "type": "image/png"
        },
        {
            "src": "img/df1a52c0-a9dd-8923-51c4-8fd1ab582afb.webPlatform.png",
            "sizes": "152x152",
            "type": "image/png"
        },
        {
            "src": "img/19a904d2-30d1-720c-52b6-c44bd97cf378.webPlatform.png",
            "sizes": "120x120",
            "type": "image/png"
        },
        {
            "src": "img/3235cb0f-c9a5-2a68-d389-9168a8afe050.webPlatform.png",
            "sizes": "76x76",
            "type": "image/png"
        },
        {
            "src": "img/android-launchericon-512-512.png",
            "sizes": "512x512",
            "type": "image/png"            
        }
    ]
}

注目する設定としては、displayプロパティの指定でネイティブアプリを起動するような形でWebアプリを起動することができます。
pwabuilder.comからマニフェストファイルを作成できる対象は公開されているサイトになります。
今回はFirebase上にデプロイしてサイトを公開しています。

Service WorkerのJavaScriptファイルを作成する

Build Service Workerでの手順です。
Cache-first networkを選択します。
以下のファイルが作成されます。

pwabuilder-sw-register.js

//This is the service worker with the Cache-first network

//Add this below content to your HTML page, or add the js file to your page at the very top to register service worker
if (navigator.serviceWorker.controller) {
  console.log('[PWA Builder] active service worker found, no need to register')
} else {

//Register the ServiceWorker
  navigator.serviceWorker.register('pwabuilder-sw.js', {
    scope: './'
  }).then(function(reg) {
    console.log('Service worker has been registered for scope:'+ reg.scope);
  });
}

index.htmlで読み込むJavaScriptファイルになります。
ServiceWorkerを登録するために利用します。

pwabuilder-sw.js

//This is the service worker with the Cache-first network

var CACHE = 'pwabuilder-precache';
var precacheFiles = [
      /* Add an array of files to precache for your app */
      'vendor/bootstrap/css/bootstrap.min.css',
      'vendor/font-awesome/css/font-awesome.min.css',
      'vendor/magnific-popup/magnific-popup.css',
      'css/freelancer.min.css',
      'img/profile.png',
      'img/portfolio/cabin.png',
      'img/portfolio/cake.png',
      'img/portfolio/circus.png',
      'img/portfolio/game.png',
      'img/portfolio/safe.png',
      'img/portfolio/submarine.png',
      'vendor/jquery/jquery.min.js',
      'vendor/bootstrap/js/bootstrap.bundle.min.js',
      'vendor/jquery-easing/jquery.easing.min.js',
      'vendor/magnific-popup/jquery.magnific-popup.min.js',
      'js/jqBootstrapValidation.js',
      'js/contact_me.js',
      'js/freelancer.min.js',
      'manup.min.js'
    ];

//Install stage sets up the cache-array to configure pre-cache content
self.addEventListener('install', function(evt) {
  console.log('The service worker is being installed.');
  evt.waitUntil(precache().then(function() {
    console.log('[ServiceWorker] Skip waiting on install');
      return self.skipWaiting();

  })
  );
});


//allow sw to control of current page
self.addEventListener('activate', function(event) {
console.log('[ServiceWorker] Claiming clients for current page');
      return self.clients.claim();

});

self.addEventListener('fetch', function(evt) {
  console.log('The service worker is serving the asset.'+ evt.request.url);
  if(!evt.request.url.includes('https')){
    return;
  }
  evt.respondWith(fromCache(evt.request).catch(fromServer(evt.request)));
  evt.waitUntil(update(evt.request));
});


function precache() {
  return caches.open(CACHE).then(function (cache) {
    return cache.addAll(precacheFiles);
  });
}


function fromCache(request) {
  //we pull files from the cache first thing so we can show them fast
  return caches.open(CACHE).then(function (cache) {
    return cache.match(request).then(function (matching) {
      return matching || Promise.reject('no-match');
    });
  });
}


function update(request) {
  //this is where we call the server to get the newest version of the 
  //file to use the next time we show view
  return caches.open(CACHE).then(function (cache) {
    return fetch(request).then(function (response) {
      return cache.put(request, response);
    });
  });
}

function fromServer(request){
  //this is the fallback if it is not in the cache to go to the server and get it
return fetch(request).then(function(response){ return response})
}

ServiceWorkerによるキャッシュを行うJavaScriptファイルになります。
precacheFilesという配列にキャッシュを行うファイルを手動で記述する必要があります。

PWAとして構築する

WebでDownloadを選択します。

Web App Manifestの設定

  • manifest.jsonとiconのイメージを配置します。
  • manifest.jsonを読み込みます。
<link rel="manifest" href="manifest.json"></link>
  • Web App Manifestのpolyfillを適用します。
<script src="manup.js"></script>

ファイルの場所
https://github.com/boyofgreen/manUp.js/

Service Workerの設定

  • pwabuilder-sw-register.jsを読み込みます。
<script src="pwabuilder-sw-register.js"></script>

結果

Android端末にインストールしてみました。
オフライン時でも表示ができています。

f:id:hi1280:20180618214555g:plain

Angular+FirebaseのWebアプリを公開するために実施したこと

過去に記事にしたWebアプリを公開しました。

bookshelf-share.site

技術的なことは下記記事を見てください。

hi1280.hatenablog.com

この記事では、アプリの開発以外で実際に公開するために実施したことを紹介します。

目次

テスト環境の作成

公開環境でテストをするわけにはいかないので、テストのための環境を用意する必要があります。

Firebase上で別プロジェクトを作成して、テスト環境を用意しました。
FirebaseのCLIツールでは、一つの作業ディレクトリで複数のプロジェクトを扱うことができます。

参考URL
Firebase CLI リファレンス  |  Firebase

Angular CLIでAngularプロジェクトを作成した場合にenvironmentsで環境毎の設定変更が可能です。
アプリ内での環境変更はこれを活用しました。
/src/environments/にテスト環境用と本番環境用の設定を用意しました。

独自ドメインの設定

お名前.comでドメインを取得しました。
Firebaseの場合、FirebaseのコンソールにあるHostingのメニューから独自ドメインを設定することができます。
SSL証明書も自動的に付いてきます。
Firebaseの情報に従って、お名前.comのDNSサーバにDNSレコードを設定しました。

参考URL
Connect a Custom Domain  |  Firebase

トップページにアプリの紹介を載せる

トップページには、どんなアプリなのかを理解してもらうための簡潔な説明を書きました。
いらすとやから画像を拝借しています。

かわいいフリー素材集 いらすとや

問い合わせ先の記載

メールアドレスの用意

Webアプリの問い合わせ先を用意します。
問い合わせ先として独自ドメインのメールアドレスを用意しました。
参考にしたサイトが独自ドメインのメールアドレスを用意していたので、そういうものなんだと思っています。
独自ドメインのメールアドレスを用意するのにお名前メールを使いました。

失敗したこと
お名前メール用の共用DNSサーバを使う必要があるようで、Firebaseへのホスティング用に使用したDNSサーバは使えずにドメイン名が異なるメールアドレスになってしまいました。
契約した後に気付いたので、これはそのままになっています。

利用規約とプライバシーポリシーの記載

Webアプリのルールを定めたものとして利用規約とプライバシーポリシーを用意する必要があるようです。

作成にあたっては、こちらを参考にしました。
Webサイトの利用規約(無料テンプレート・商用利用可)

見よう見まねです。

その頃よくチェックしていた技術書典のサイトも大いに参考にしました。
技術書典

著作権表示の記載

著作権表示を慣習として用意します。
著作権表示には正式な書き方があるようです。
色々な書き方がありますが、下記の内容が表記されていれば十分のようです。

  • Copyright表記
  • 著作物の発行年号
  • 著作権所有者の氏名

こちらを参考にしました。
Copyright(コピーライト:著作権表示)の正しい書き方を知っていますか?:webサイト制作 - webデザイン初心者|sometimes study

こちらも見よう見まねです。

Googleアナリティクスを追加

アプリをどれくらいのユーザが利用しているのかトラッキングしたいので、Googleアナリティクスを導入します。
AngularでGoogleアナリティクスを使うためのポイントは下記の通りです。

  • index.htmlにGoogleアナリティクスが提供するJavaScriptのトラッキングコードを記載する
  • ルーティングの際にページを表示したデータを送る

app.component.ts

...
constructor(private router: Router) {
  this.router.events.subscribe(event => {
    if (event instanceof NavigationEnd) {
      (<any>window).ga('set', 'page', event.urlAfterRedirects);
      (<any>window).ga('send', 'pageview');
    }
  });
}
...

参考URL
Using Google Analytics with Angular – codeburst

JavaScriptのエラートラッキングを追加

今回のアプリの構成だとサーバがないためにエラーが発生してもクライアントでしか分かりません。
クライアントのエラー情報を収集するための仕組みを用意する必要がありました。

このためにSentryというサービスを使いました。
Sentry | Error Tracking Software — JavaScript, Python, PHP, Ruby, more

Angularで使うためのドキュメントが公式で用意されています。
Angular – Sentry Documentation

Sentryのコンソールからクライアントで発生したJavaScriptのエラー情報が見えるようになりました。

SNSシェアボタンの設置

Twitterはてブのボタンを設置しました。
Twitterでの表示を意図したものにするためにOGPの設定を行いました。

index.html

<meta property="og:title" content="ページタイトル" />
<meta property="og:site_name" content="サイト名" />
<meta property="og:type" content="website" />
<meta property="og:url" content="ページのURL" />
<meta property="og:description" content="ページの説明" />
<meta property="og:image" content="ページのイメージ画像" />
<meta name="twitter:card" content="summary">

ボタンの配置は公式で提供されている方法を使いました。

Twitter Publish

はてなブックマークボタンの作成・設置について - はてなブックマーク

ボタンを配置するためのコードにscriptタグが含まれていますが、
Angularではscriptタグをhtmlに記載しても無効化されてしまうので、
コンポーネントの初期処理時にscriptタグを生成するコードを記載しました。

...
constructor(
  private elementRef: ElementRef
) {}

ngOnInit() {
  const twitter = document.createElement('script');
  twitter.src = 'https://platform.twitter.com/widgets.js';
  twitter.async = true;
  twitter.charset = 'utf-8';
  this.elementRef.nativeElement.appendChild(twitter);
}
...

参考URL
javascript - script tag in angular2 template / hook when template dom is loaded - Stack Overflow

まとめ

友達から気軽に本を借りたいという個人的な動機から生まれたアプリです。
今後も使い勝手を向上するために改善して行く予定です。

bookshelf-share.site

AngularFireを使うと遭遇するRxJSでのハマりどころ

Angularを使うと漏れなくRxJSを使うことになります。
非同期処理を命令的に書くことがなくなるメリットがある反面、
宣言的プログラミングに慣れていない人にとってはRxJSを理解することが難しいです。
AngularFireを使ってWebアプリを作成する中で、遭遇したハマりどころを紹介します。
この記事では、RxJSの基本的なことは説明しません。

意図せずに処理が呼ばれる

AngularFireを使った場合にストリームがどういったタイミングで流れるかを意識しないと意図しないタイミングで処理が実行されてしまいます。
例えば、DBからデータを取得して、そのデータを条件にしてデータを削除する場合などです。

悪い例

// getUserはユーザの情報を取得して、レスポンスをvalueChangesメソッドによるObservableで返す関数です
// deleteItemは項目を削除する疑似関数です

getUser(id).subscribe((user) => {
    deleteItem(user.itemId);
});

getUser(id: string): Observable<{}> {
    return this.db.doc(`users/${id}`).valueChanges();
}

この場合、ユーザの情報を常に変更検知しているため、ユーザを作成したタイミングで、またこの処理が実行されてしまう可能性があります。

良い例

getUser(id).take(1).subscribe((user) => {
    deleteItem(user.itemId);
});

takeで処理を実行する回数を1回と指定することで、予期しない処理の実行を防止することができます。

コードのネストが深くなる

Observableによる連続的な非同期処理をsubscribeで処理すると非同期処理ごとにネストが深くなってしまいます。

悪い例

// getUserはユーザの情報、getUserDetailはユーザの詳細情報を取得し、レスポンスをObservableで返す疑似関数です。

getUser(id).subscribe(user => {
    getUserDetail(user.id).subscribe(detail => {
        // 処理内容
    });
});

良い例

getUser(id)
    .mergeMap(user => getUserDetail(user.id))
    .subscribe(detail => {
        // 処理内容
    });

mergeMapを使用することで元のストリームから異なるストリームに切り替えることができます。
イメージ的にはPromiseのthenを使って非同期処理を変えるのに似ていると思います。
このようにストリームを切り替えるオペレータにはmergeMapの他にconcatMap、switchMap、exhaustMapなどがあります。

mergeMapでデータが存在しない場合の条件分岐

mergeMapではデータが存在しない場合でもObservableを返す必要があります。

問題

// getUserはユーザの情報、getUserDetailはユーザの詳細情報を取得し、レスポンスをObservableで返す疑似関数です。

getUser(id)
    .mergeMap(user => {
        // userが存在しなかった場合にuser.idでundefinedで落ちる
        return getUserDetail(user.id);
    });

解決策

getUser(id)
    .mergeMap(user => {
        if(!user) {
            return Observable.empty();
        }
        return getUserDetail(user.id);
    });

emptyで何も値を持たないObservableを返します。

DBから取得したリストそれぞれの項目にデータを付与したい

リスト内の各データを条件に他のデータを参照して、リスト内に参照したデータを追記したいというケースがあります。
例えば、ユーザの一覧とユーザの詳細を結合して、一つの一覧で表示したい場合です。

解決策

// getUsersはユーザの一覧、getUserDetailはユーザの詳細情報を取得し、レスポンスをObservableで返す疑似関数です。

getUsers().map(users => {
    return users.map(user => {
        return getUserDetail(user.id).map(detail => {
            return {...user, ...detail};
        });
    });
}).mergeMap(users => {
    return Observable.combineLatest(users);
});

mapをネストさせることで、ユーザの一覧の各データに対して、ユーザの詳細情報を追記しています。
mapのネストによってObservableの中にObservableがある状態になりますが、最終的にcombineLatestでObservableに変えてます。
combineLatestはストリーム内の値を結合することができるので、結果的に中身のObservableだけを取り出しています。

おわりに

最後の例はこれで問題ないのか結構怪しいです。
もっと良い方法があれば知りたいです。