技術メモ

技術メモ

ラフなメモ

リーダブルな文章を書くためのおすすめ書籍3選

文書の分かりにくさは「表現の分かりにくさ」と「文脈の分かりにくさ」に大別できる1。本記事では主に前者の「表現の分かりにくさ」を改善するためにおすすめできる書籍を紹介する。

後者の「文脈の分かりにくさ」は読み手に依存する。日本語としては理解できるが頭に入ってこない文章、が存在するのは直感的に受け入れられるだろう。読み手の前提を把握し、読み手の論点に向かって文章を書く必要があるが、本記事ではここには踏み込まない。

1.理工系のためのよい文章の書き方

www.shoeisha.co.jp

よい文章を書くために必要な全体像がつかめる。原則、文章構成や文の書き方、心得などを説明している。190ページ程度で簡潔にまとまっているのが良い。書いてあることは基本的な内容であり、著者も「どちらかというと学生向けを意識して書いたものである2」と述べているが、多くの社会人にとっても役に立つだろう。

『理科系の作文技術3』も本書同様に文章構成、主題の決め方など、よい文章や論文、レポートを書くために必要な項目を網羅的に解説しているが、冗長と感じている。基本的な文章の書き方を学びたいメンバーには『理工系のためのよい文章の書き方』をすすめたい。

2.日本語作文術

www.chuko.co.jp

特に第一章の「作文術の心得 ―短文道場―」が良い。分かりやすい文を書く技術が学べる。以下のような意識しやすい、あるいは実践しやすい内容が豊富に記載してある。

  • 考えがしっかりまとまっていないから、文が長くなる
  • 「音読」は文章を推敲するときに非常に大切なことである
  • 読みづらい箇所はたいてい文の組み立て方か、言葉づかいに難点がある
  • 分かりやすい文章を書く上でいちばん大切な心構えは「読み手の身になって書け」ということだ
  • ハとガの使い分けは文法的な問題ではなくて、使う人の視点(スタンス)の問題ということだ

3.文章力の基本

www.njg.co.jp

『日本語作文術』の短文道場、あるいは『理科系の作文技術』の第8章(わかりやすく簡潔な表現)をより平易に解説しているような書籍である。悪い例と良い例を示し、改善ポイントは何であるかを解説している。例が豊富でイメージしやすく、入門書として良い。難点は、悪い例がわざとらしいというか、いかにも初心者の文と感じるものが多いことである。『日本語作文術』には「文章指南書がよくやる素人の作例は絶対に使わない。あれはやっている本人はご満悦かもしれないが、一種の弱いものいじめだ」とある。4『文章力の基本』での例はそれに近い。好き嫌いは分かれそうである。


  1. https://blog.tinect.jp/?p=60953
  2. https://twitter.com/kentarofukuchi/status/1594484904766500864
  3. https://www.chuko.co.jp/shinsho/1981/09/100624.html
  4. ちなみに『日本語作文術』でも悪い文を例示し、良い文に改善する方法を紹介している。ただし、例として挙げているのは素人の文章ではなく、横溝正史鈴木大拙といった日本の文豪が書いた文章である。

HIGH OUTPUT MANAGEMENTのミーティングの章メモ

はじめに

普段の仕事の中でミーティングをする機会はとても多いです。朝会などのチームメンバで毎日実施するようなミーティングや、週次で実施しているプロジェクトの定例、顧客との定例、関係者と業務やシステムの仕様を調整するようなミーティング、などなど様々あります。簡単に開けてしまうミーティングですが、ミーティングの場を有意義な時間にするには、ミーティングの目的やミーティングの中で何を実施しなければいけないのか、どのような準備が必要なのか考慮することはたくさんあります。

本記事では HIGH OUTPUT MANAGEMENT で紹介されているミーティングの章からミーティングには同様な種類があるのか、何を行うのか紹介します。書籍を読んだメモです。

ミーティングの種類

ミーティングの種類について HIGH OUTPUT MANAGEMENT では以下にように分類しています。

f:id:tutuz:20210307104135p:plain
ミーティングの分類

大きくミーティングは2種類に大別でき、知識の共有化や情報を交換を目的とした「プロセス中心」のミーティングと、具体的な問題解決を目的とした「使命中心」のミーティングがあります。

プロセス中心のミーティング

プロセス中心のミーティングは大きく3つ存在する(インテル社の場合)。

  • 1on1
  • スタッフミーティング
  • 業務検討会

である。

1on1

  • どのようなミーティングか
    • 監督者と部下の間のミーティング。部下のためのミーティング
  • 目的
    • 相互に教えたり、情報を交換すること
  • ミーティングの構成/議題
    • 部下が直面していることや困っていること

スタッフミーティング

  • どのようなミーティングか
    • ある監督者とその部下全員が参加する部内会議
  • 目的
    • 同僚相互の交流、監督者が物事を知る機会
  • ミーティングの構成/議題
    • 出席者3人以上に関係する任意の事項

業務検討会

  • どのようなミーティングか
    • 相互に話し合う機会があまりない人々のための意見交換の場、手段
  • 目的
    • 組織階層が離れていて交流のない社員間で教育と学習を継続させようとするもの
  • ミーティングの構成/議題
    • 上級の管理者がプレゼンを実施
    • 管理者の上司がアジェンダをフォロー

使命中心のミーティング

  • どのようなミーティングか
    • 特別な目的のために随時開かれるミーティング
  • 目的
    • 特定の成果、一定の意思決定に到達すること
  • ミーティングの構成/議題
    • 都合をつけて関係者を確実にミーティングに参加させる

GoでJSTのタイムゾーンを扱う方法

本記事はGoでJSTタイムゾーンを指定する方法を紹介します。

現在時刻を取得するには time.Now() を使うことになります。time.Now() はデフォルトではローカルな時刻が取得できます。例えばAWS Lambda上ではUTCの時刻が取得できます。日本のロケーションで動作するアプリケーションを前提にすると、アプリケーションによっては時刻をJSTで統一したほうがシンプルで扱いやすい、といったケースもあるでしょう。本記事ではGoのアプリケーションでJSTの時刻を取得する方法を紹介します。

*time.Location の取得方法

まずタイムゾーン (time.Location) について説明します。Goではグローバル変数として *time.Local 変数があります。アプリケーションで更新することはアンチパターンとされているため、変数を上書きすることはないでしょう。

// Local represents the system's local time zone.
// On Unix systems, Local consults the TZ environment
// variable to find the time zone to use. No TZ means
// use the system default /etc/localtime.
// TZ="" means use UTC.
// TZ="foo" means use file foo in the system timezone directory.
var Local *Location = &localLoc

*time.Location を取得するには主に以下のAPIを使います。LoadLocationFromTZData を使用する機会は多くはないでしょう。

  • time.FixedZone
  • time.LoadLocation
  • LoadLocationFromTZData

time.FixedZone を用いて *time.Location を生成することができます。

   myJST := time.FixedZone("Asia/Tokyo", 9*60*60)

または time.LoadLocation でロケーション名を指定して *time.Location を取得します。

   jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }

time.LoadLocation は外部依存があるため scratch などの軽量コンテナ上では動作しません。ソースはランタイムごとに異なりますが、例えばUNIX系の場合は zoneinfo_unix.go から確認できます。

以下のようにして確認することができます。

  • Dockerfile
FROM golang:1.15 as builder

WORKDIR /go/src/app

COPY go.mod go.sum ./
RUN go mod download

COPY ./main.go  ./

ARG CGO_ENABLED=0
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build -o /go/bin/main -ldflags '-s -w' -trimpath


FROM scratch as runner

COPY --from=builder /go/bin/main /app/main

ENTRYPOINT ["/app/main"]
  • main.go
package main

import "time"

func main() {
    _, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
}
  • コンテナのビルド&実行
$ docker build -t jst-time-test1 -f Dockerfile .
$ docker run --rm jst-time-test1
  • 実行結果
panic: unknown time zone Asia/Tokyo

goroutine 1 [running]:
main.main()
        tzsample/light/main.go:8 +0x65

time.LoadLocation を使ってタイムゾーンを取得する場合は、アプリケーションが動作するランタイム上で動作するか確認する必要があります。ランタイムにタイムゾーンの情報が含まれない場合は

  • time.FixedZone を使ってタイムゾーンのオフセットを決め打ちする
  • ビルド時に -tags timetzdata を付与 1 してビルドする

などといった回避方法があります。

タイムゾーンを指定して時刻を取得する方法

続いて現在時刻を取得する方法です。time.Now() で取得する時刻にロケーションを指定する方法は以下の2種類の方法があります。

"Asia/Tokyo"タイムゾーンを指定する文字列は環境変数から値を設定することももちろん可能です。

方法1.時刻をパースする際に In でロケーションを指定する

もっともポピュラーな変換方法です。

   jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    nowJST := time.Now().In(jst)
    fmt.Printf("nowJST: %v\n", nowJST.Format(time.RFC3339))
    // nowJST: 2009-11-11T08:00:00+09:00

https://play.golang.org/p/BRrCCx4qpcP

方法2. time パッケージが保持しているグローバル変数を更新する

time パッケージが保持しているグローバル変数 time.LocalJSTで更新する方法です。グローバル変数を更新することで間接的に time.Now() で取得できるタイムゾーンを差し替えることができます。

func init() {
    jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    time.Local = jst
}

func main() {
    now := time.Now()
    fmt.Printf("now: %v\n", now.Format(time.RFC3339))
    // now: 2009-11-11T08:00:00+09:00
}

https://play.golang.org/p/UkMP0dxlOyE

方法1と方法2を比較すると、方法1では時刻を取得するすべての実装箇所から In を用いてタイムゾーンの変換をする必要があります。変換漏れがあると時刻がローカルな時刻とJSTと混在することになるため、一見方法2が一律指定できて、不具合が少ないと考えるかも知れません。

しかし方法2は筋がいい実装と言えません。もちろんミュータブルなグローバル変数を更新することで実現できますが、意図せずいろいろなところに影響が及ぶ可能性があります。実際にRuss Coxも次のようにほとんどの場合は間違いである、と述べています。

I've updated the title. What you're asking for is

``` package time

// SetLocal changes the current meaning of time.Local to match loc. func SetLocal(loc *Location) I don't believe this is a good idea. In general but especially in Go, coordination via global mutable state is almost always a mistake and difficult to correct once you realize it. ```

If there are higher-level APIs that force the use of time.Local, we should probably address those instead. For example, if package log is what you are concerned about, it would probably be okay to file a separate issue for adding log.SetLocation and log.(*Logger).SetLocation.

https://github.com/golang/go/issues/10701#issuecomment-148777945

実際、以下のように init 内で初期化した time.Local の変数とテスト中に time.Local を読み込むと DATA RACE が発生する、という事象もあります。私も再現しました。

方法1'.ロケーション付で時刻が取得できる関数を生成

方法1の拡張版です。In で変換するが、漏れのないように時刻を取得する関数を新たに生成して、その関数経由で時刻を取得する方法です。時刻を取得するときにアプリケーション側で宣言した nowFunc を使う必要がありますが、方法2と比較すると安全な方法です。

var nowFunc func() time.Time

func init() {
    jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }

    nowFunc = func() time.Time {
        return time.Now().In(jst)
    }
}

func main() {
    now := nowFunc()
    fmt.Printf("now: %v\n", now.Format(time.RFC3339))
    // now: 2009-11-11T08:00:00+09:00
}

https://play.golang.org/p/uy6zILsixiZ

Songmu/flextime などの時刻を差し替えることができるライブラリなどを用いれば、上記同様に時刻のタイムゾーン変換ができます。以下は Songmu/flextimeNowFunc を用いて時刻を差し替えています。

func init() {
    tz, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }

    flextime.NowFunc(func() time.Time {
        return time.Now().In(tz)
    })
}

func main() {
    now := flextime.Now()
    fmt.Printf("now: %v\n", now.Format(time.RFC3339))
    // now: 2009-11-11T08:00:00+09:00
}

https://play.golang.org/p/srJJReRbun_M

文字列から time.Time に変換する方法

日付の文字列 2021-01-01 から time.Time 型に変換するには以下のAPIを使います。

  • time.Parse
  • time.ParseInLocation

time.Parseタイムゾーンが文字列に含まれていない場合、デフォルトでUTCに変換します。

func main() {
    d := "2021-01-30"
    nowDate, err := time.Parse("2006-01-02", d)
    if err != nil {
        panic(err)
    }
    fmt.Printf("nowDate: %v", nowDate.Format(time.RFC3339))
    // nowDate: 2021-01-30T00:00:00Z
}

変換時にタイムゾーンが重要な場合は time.ParseInLocation で明示的にタイムゾーンを指定して文字列から変換しましょう。

func main() {
    d := "2021-01-30"
    tz, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    nowDateJST, err := time.ParseInLocation("2006-01-02", d, tz)
    if err != nil {
        panic(err)
    }
    fmt.Printf("nowDateJST: %v", nowDateJST.Format(time.RFC3339))
    // nowDateJST: 2021-01-30T00:00:00+09:00
}

まとめ

*time.Locationグローバル変数を更新してタイムゾーンを変換する方法は危険なのでやめましょう。現在時刻をJSTで取得する場合は In を使って変換する方法が良いです。ラッパーをかますと、安全にかつ漏れが少なく変換することができます。


  1. Go1.15からビルド時に -tags timetzdata を付与してビルドすることで、タイムゾーン情報を埋め込むことができるようになりました。詳細は https://golang.org/pkg/time/tzdata/ に記載されています。

GoDownloaderを使ってCLIツールのインストーラを作る

Goで書いたCLIツールのバイナリ配布には GoReleaser が便利だよ、というのはよく知られていることです。GoReleaser ではYAML形式の .goreleaser.yml ファイルという設定ファイルを記述し、GitHub Actionsなどと連携してリリースを自動化することができます。

GoReleaser を用いたバイナリの配布の話は

などを参考にしてください。

GoDownloader

本記事では GoReleaser で作成したバイナリのインストーラを作成する方法を紹介します。GoDownloader を使います。

github.com

GoDownloaderのインストール

ローカルへのインストール方法は本来ですと go get github.com/goreleaser/godownloader で取得できそうですが、2021/01/24現在は以下のように invalid pseudo-version によってダウンロードできません。replace ディレクティブを駆使することで対応は可能です。

$ go get github.com/goreleaser/godownloader
go get: github.com/goreleaser/godownloader@none updating to
        github.com/goreleaser/godownloader@v0.1.0 requires
        github.com/goreleaser/goreleaser@v0.118.2 requires
        code.gitea.io/gitea@v1.10.0-dev.0.20190711052757-a0820e09fbf7 requires
        github.com/go-macaron/cors@v0.0.0-20190309005821-6fd6a9bfe14e9: invalid pseudo-version: revision is longer than canonical (6fd6a9bfe14e)

以下の2つのどちらかの方法が良いでしょう。

  • 1.Releasesからv0.1.0のバイナリを取得する
$ curl -OL https://github.com/goreleaser/godownloader/releases/download/v0.1.0/godownloader_0.1.0_Linux_x86_64.tar.gz
$ sudo tar -zxvf godownloader_0.1.0_Linux_x86_64.tar.gz -C /usr/local/bin
  • 2.git clone してソースからビルドする
$ git clone --quiet --depth 1 https://github.com/goreleaser/godownloader && cd godownloader

Makefile に以下のようにタスクを書くと便利です。

repo_name := d-tsuji/qiisync
current_dir := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))

installer:
    sh -c '\
      tmpdir=$$(mktemp -d); \
      cd $$tmpdir; \
      git clone --quiet --depth 1 https://github.com/goreleaser/godownloader && cd godownloader; \
      go run . -f -r ${repo_name} -o ${current_dir}/install.sh; \
      rm -rf $$tmpdir'

使い方

とても簡単です。GitHub.goreleaser.yml がコミットされている状態で以下のようにコマンドを実行するだけです。

$ godownloader -f -r d-tsuji/qiisync -o install.sh
   • reading repo "d-tsuji/qiisync" on github
   • reading https://raw.githubusercontent.com/d-tsuji/qiisync/master/goreleaser.yml
   ⨯ reading https://raw.githubusercontent.com/d-tsuji/qiisync/master/goreleaser.yml returned 404 Not Found

   • reading https://raw.githubusercontent.com/d-tsuji/qiisync/master/.goreleaser.yml
   • setting defaults for loading environment variables
   • setting defaults for snapshoting
   • setting defaults for GitHub/GitLab/Gitea Releases
   • setting defaults for project name
   • setting defaults for building binaries
   • setting defaults for archives
   • setting defaults for Linux packages with nfpm
   • setting defaults for Snapcraft Packages
   • setting defaults for calculating checksums
   • setting defaults for signing artifacts
   • setting defaults for Docker images
   • setting defaults for Artifactory
   • setting defaults for S3
   • setting defaults for Blob
   • setting defaults for homebrew tap formula
   • setting defaults for scoop manifest

上記のコマンドで指定しているオプションは以下のとおりです。

オプション 説明 デフォルト
-f 出力されるファイルを上書きするか false
-r GitHubリポジトリ -
-o 出力されるファイル名 標準出力

実行すると install.sh が作成されます。

例えば以下のような .goreleaser.yml がコミットされている場合は次のような install.sh が生成されます。

.goreleaser.yml

project_name: qiisync
before:
hooks:
    - go mod tidy
builds:
-
    goos:
    - linux
    - darwin
    - windows
    goarch:
    - amd64
    - 386
    main: ./cmd/qiisync
    binary: qiisync
    ldflags:
    - -s -w
    - "-X main.version={{.Version}}"

archives:
- format: zip
    name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
    files:
    - LICENSE
    - README.md

nfpms:
-
    formats:
    - deb
    - rpm
    vendor: "d-tsuji"
    homepage: "https://github.com/d-tsuji/qiisync"
    maintainer: "Tsuji Daishiro"
    description: "Qiita CLI tool, support push and pull from/to local filesystem and Qiita."
    license: "MIT"
    file_name_template: "{{.ProjectName}}_{{.Version}}_{{.Os}}-{{.Arch}}"
    replacements:
    amd64: 64-bit
    386: 32-bit
    darwin: macOS
    linux: Tux

brews:
- description: "Qiita CLI tool, support push and pull from/to local filesystem and Qiita."
    github:
    owner: d-tsuji
    name: homebrew-qiisync
    commit_author:
    name: goreleaserbot
    email: goreleaser@carlosbecker.com
    homepage: "https://github.com/d-tsuji/qiisync"
    install: |
    bin.install "qiisync"
    test: |
    system "#{bin}/qiisync"

checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"

install.sh

#!/bin/sh
set -e
# Code generated by godownloader on 2021-01-23T14:08:53Z. DO NOT EDIT.
#

usage() {
  this=$1
  cat <<EOF
$this: download go binaries for d-tsuji/qiisync

Usage: $this [-b] bindir [-d] [tag]
  -b sets bindir or installation directory, Defaults to ./bin
  -d turns on debug logging
   [tag] is a tag from
   https://github.com/d-tsuji/qiisync/releases
   If tag is missing, then the latest will be used.

 Generated by godownloader
  https://github.com/goreleaser/godownloader

EOF
  exit 2
}

parse_args() {
  #BINDIR is ./bin unless set be ENV
  # over-ridden by flag below

  BINDIR=${BINDIR:-./bin}
  while getopts "b:dh?x" arg; do
    case "$arg" in
      b) BINDIR="$OPTARG" ;;
      d) log_set_priority 10 ;;
      h | \?) usage "$0" ;;
      x) set -x ;;
    esac
  done
  shift $((OPTIND - 1))
  TAG=$1
}
# this function wraps all the destructive operations
# if a curl|bash cuts off the end of the script due to
# network, either nothing will happen or will syntax error
# out preventing half-done work
execute() {
  tmpdir=$(mktemp -d)
  log_debug "downloading files into ${tmpdir}"
  http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}"
  http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}"
  hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}"
  srcdir="${tmpdir}"
  (cd "${tmpdir}" && untar "${TARBALL}")
  test ! -d "${BINDIR}" && install -d "${BINDIR}"
  for binexe in $BINARIES; do
    if [ "$OS" = "windows" ]; then
      binexe="${binexe}.exe"
    fi
    install "${srcdir}/${binexe}" "${BINDIR}/"
    log_info "installed ${BINDIR}/${binexe}"
  done
  rm -rf "${tmpdir}"
}
get_binaries() {
  case "$PLATFORM" in
    darwin/386) BINARIES="qiisync" ;;
    darwin/amd64) BINARIES="qiisync" ;;
    linux/386) BINARIES="qiisync" ;;
    linux/amd64) BINARIES="qiisync" ;;
    windows/386) BINARIES="qiisync" ;;
    windows/amd64) BINARIES="qiisync" ;;
    *)
      log_crit "platform $PLATFORM is not supported.  Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new"
      exit 1
      ;;
  esac
}
tag_to_version() {
  if [ -z "${TAG}" ]; then
    log_info "checking GitHub for latest tag"
  else
    log_info "checking GitHub for tag '${TAG}'"
  fi
  REALTAG=$(github_release "$OWNER/$REPO" "${TAG}") && true
  if test -z "$REALTAG"; then
    log_crit "unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details"
    exit 1
  fi
  # if version starts with 'v', remove it
  TAG="$REALTAG"
  VERSION=${TAG#v}
}
adjust_format() {
  # change format (tar.gz or zip) based on OS
  true
}
adjust_os() {
  # adjust archive name based on OS
  true
}
adjust_arch() {
  # adjust archive name based on ARCH
  true
}

cat /dev/null <<EOF
------------------------------------------------------------------------
https://github.com/client9/shlib - portable posix shell functions
Public domain - http://unlicense.org
https://github.com/client9/shlib/blob/master/LICENSE.md
but credit (and pull requests) appreciated.
------------------------------------------------------------------------
EOF
is_command() {
  command -v "$1" >/dev/null
}
echoerr() {
  echo "$@" 1>&2
}
log_prefix() {
  echo "$0"
}
_logp=6
log_set_priority() {
  _logp="$1"
}
log_priority() {
  if test -z "$1"; then
    echo "$_logp"
    return
  fi
  [ "$1" -le "$_logp" ]
}
log_tag() {
  case $1 in
    0) echo "emerg" ;;
    1) echo "alert" ;;
    2) echo "crit" ;;
    3) echo "err" ;;
    4) echo "warning" ;;
    5) echo "notice" ;;
    6) echo "info" ;;
    7) echo "debug" ;;
    *) echo "$1" ;;
  esac
}
log_debug() {
  log_priority 7 || return 0
  echoerr "$(log_prefix)" "$(log_tag 7)" "$@"
}
log_info() {
  log_priority 6 || return 0
  echoerr "$(log_prefix)" "$(log_tag 6)" "$@"
}
log_err() {
  log_priority 3 || return 0
  echoerr "$(log_prefix)" "$(log_tag 3)" "$@"
}
log_crit() {
  log_priority 2 || return 0
  echoerr "$(log_prefix)" "$(log_tag 2)" "$@"
}
uname_os() {
  os=$(uname -s | tr '[:upper:]' '[:lower:]')
  case "$os" in
    cygwin_nt*) os="windows" ;;
    mingw*) os="windows" ;;
    msys_nt*) os="windows" ;;
  esac
  echo "$os"
}
uname_arch() {
  arch=$(uname -m)
  case $arch in
    x86_64) arch="amd64" ;;
    x86) arch="386" ;;
    i686) arch="386" ;;
    i386) arch="386" ;;
    aarch64) arch="arm64" ;;
    armv5*) arch="armv5" ;;
    armv6*) arch="armv6" ;;
    armv7*) arch="armv7" ;;
  esac
  echo ${arch}
}
uname_os_check() {
  os=$(uname_os)
  case "$os" in
    darwin) return 0 ;;
    dragonfly) return 0 ;;
    freebsd) return 0 ;;
    linux) return 0 ;;
    android) return 0 ;;
    nacl) return 0 ;;
    netbsd) return 0 ;;
    openbsd) return 0 ;;
    plan9) return 0 ;;
    solaris) return 0 ;;
    windows) return 0 ;;
  esac
  log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib"
  return 1
}
uname_arch_check() {
  arch=$(uname_arch)
  case "$arch" in
    386) return 0 ;;
    amd64) return 0 ;;
    arm64) return 0 ;;
    armv5) return 0 ;;
    armv6) return 0 ;;
    armv7) return 0 ;;
    ppc64) return 0 ;;
    ppc64le) return 0 ;;
    mips) return 0 ;;
    mipsle) return 0 ;;
    mips64) return 0 ;;
    mips64le) return 0 ;;
    s390x) return 0 ;;
    amd64p32) return 0 ;;
  esac
  log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value.  Please file bug report at https://github.com/client9/shlib"
  return 1
}
untar() {
  tarball=$1
  case "${tarball}" in
    *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;;
    *.tar) tar --no-same-owner -xf "${tarball}" ;;
    *.zip) unzip "${tarball}" ;;
    *)
      log_err "untar unknown archive format for ${tarball}"
      return 1
      ;;
  esac
}
http_download_curl() {
  local_file=$1
  source_url=$2
  header=$3
  if [ -z "$header" ]; then
    code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url")
  else
    code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url")
  fi
  if [ "$code" != "200" ]; then
    log_debug "http_download_curl received HTTP status $code"
    return 1
  fi
  return 0
}
http_download_wget() {
  local_file=$1
  source_url=$2
  header=$3
  if [ -z "$header" ]; then
    wget -q -O "$local_file" "$source_url"
  else
    wget -q --header "$header" -O "$local_file" "$source_url"
  fi
}
http_download() {
  log_debug "http_download $2"
  if is_command curl; then
    http_download_curl "$@"
    return
  elif is_command wget; then
    http_download_wget "$@"
    return
  fi
  log_crit "http_download unable to find wget or curl"
  return 1
}
http_copy() {
  tmp=$(mktemp)
  http_download "${tmp}" "$1" "$2" || return 1
  body=$(cat "$tmp")
  rm -f "${tmp}"
  echo "$body"
}
github_release() {
  owner_repo=$1
  version=$2
  test -z "$version" && version="latest"
  giturl="https://github.com/${owner_repo}/releases/${version}"
  json=$(http_copy "$giturl" "Accept:application/json")
  test -z "$json" && return 1
  version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')
  test -z "$version" && return 1
  echo "$version"
}
hash_sha256() {
  TARGET=${1:-/dev/stdin}
  if is_command gsha256sum; then
    hash=$(gsha256sum "$TARGET") || return 1
    echo "$hash" | cut -d ' ' -f 1
  elif is_command sha256sum; then
    hash=$(sha256sum "$TARGET") || return 1
    echo "$hash" | cut -d ' ' -f 1
  elif is_command shasum; then
    hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1
    echo "$hash" | cut -d ' ' -f 1
  elif is_command openssl; then
    hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1
    echo "$hash" | cut -d ' ' -f a
  else
    log_crit "hash_sha256 unable to find command to compute sha-256 hash"
    return 1
  fi
}
hash_sha256_verify() {
  TARGET=$1
  checksums=$2
  if [ -z "$checksums" ]; then
    log_err "hash_sha256_verify checksum file not specified in arg2"
    return 1
  fi
  BASENAME=${TARGET##*/}
  want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)
  if [ -z "$want" ]; then
    log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'"
    return 1
  fi
  got=$(hash_sha256 "$TARGET")
  if [ "$want" != "$got" ]; then
    log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got"
    return 1
  fi
}
cat /dev/null <<EOF
------------------------------------------------------------------------
End of functions from https://github.com/client9/shlib
------------------------------------------------------------------------
EOF

PROJECT_NAME="qiisync"
OWNER=d-tsuji
REPO="qiisync"
BINARY=qiisync
FORMAT=zip
OS=$(uname_os)
ARCH=$(uname_arch)
PREFIX="$OWNER/$REPO"

# use in logging routines
log_prefix() {
    echo "$PREFIX"
}
PLATFORM="${OS}/${ARCH}"
GITHUB_DOWNLOAD=https://github.com/${OWNER}/${REPO}/releases/download

uname_os_check "$OS"
uname_arch_check "$ARCH"

parse_args "$@"

get_binaries

tag_to_version

adjust_format

adjust_os

adjust_arch

log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}"

NAME=${BINARY}_${VERSION}_${OS}_${ARCH}
TARBALL=${NAME}.${FORMAT}
TARBALL_URL=${GITHUB_DOWNLOAD}/${TAG}/${TARBALL}
CHECKSUM=${PROJECT_NAME}_${VERSION}_checksums.txt
CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM}


execute

長いですがちゃんと動作します。

CLIツールを使うユーザがバイナリをインストールするときは、以下のように実行します。

$ curl -sfL https://raw.githubusercontent.com/d-tsuji/qiisync/master/install.sh | sudo sh -s -- -b /usr/local/bin
[sudo] password for tsuji:
d-tsuji/qiisync info checking GitHub for latest tag
d-tsuji/qiisync info found version: 0.0.5 for v0.0.5/linux/amd64
Archive:  qiisync_0.0.5_linux_amd64.zip
  inflating: LICENSE
  inflating: README.md
  inflating: qiisync
d-tsuji/qiisync info installed /usr/local/bin/qiisync

このコマンド一発でインストールできるので、とても便利です。一点だけ#161 にあるようにfeatureの開発は止まっている点は注意が必要です*1。ただし v0.1.0 のリリースでも十分便利に扱うことができます。

まとめ

GoReleaser を使ってGitHubにバイナリを配布している場合は GoDownloader で簡単にインストーラを作れて便利です!

*1:機能のメンテナンスは最善を尽くされるとのことです

DynamoDBを使ったデータ設計の反省点2020

2020年に取り組んだ案件の一つですが、サーバレスで検索アプリを開発していました。インフラはAWSを採用していたため、ほぼ必然的な運びでデータストアはDynamoDBを採用しています。業務アプリケーションではデータ設計が重要で、データ設計の良し悪しでアプリケーションの実装がシンプルor複雑になります。本記事はDynamoDBのデータ設計の反省点とこうすればよかったな、という振り返り記事です。

アプリケーション概要

映画館のシートような縦×横の空間に対して、人を割り当てるためのアプリケーションです(設定はダミーですが本質的には似ている構造です)。

制約

  • 席の空間は予め決まっている
  • 席は前(最前列)から詰めて座っていく
  • 各席には座れる人の区分がきまっている(ジュニア/成人/シニア)
  • 各席は事前に予約することもできるし、映画館の現地で予約なしに直接席を決めることもできる
  • 事前に予約した場合も、最終的には現地で席を確定させる
  • 30分前までには映画館内で着席していないといけない。そうでない場合は確定した席は取り消しされて、予約可能な席になる。別の方が確保することが可能

席の状態遷移図

http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOhMYbNGrRLJUBvo5nSGWzbFTdKytzAYO0LbF6wS_hXnFXU40r6yQDVJTRCKhA0CY0AuTeHi_xwdSpOyRbp-VFQMPtrBKHH3E13rSnkUxbYhO0LbO2e06Oomg-Tnq_R7JJiVDxSzRbxmk77ruwQEnusB7pSkUze_xTas87lguwOUa0bS4FF0HW2zoUMGcfS2z380

反省点

反省点1:状態を区別するためにアイテムを分離させたこと

事前「予約」と、「確定」/「着席」の状態を区別するために2つのアイテムに分けたこと。これが一番の反省点です。なぜこのアイテム設計にしたか、というと、実は業務アプリを開発する前に、同じチームの別のメンバがPoCを実施していました。私が参画したときにはPoCである程度良好なフィードバックが得られていました。そのときに採用していたデータモデル上、事前「予約」と「確定」/「着席」の状態を別に管理していたため、この分離するモデルを採用しました。

分離したときのデータモデルのキーは以下のようなものです。

  • ハッシュキー(hkey)
    • 日付をもつ
  • ソートキー(skey)
    • 席の座標や状態を持つ

例えば日付が 20210101 で座標が縦=A、横=1の席で「空」の場合は以下の2つのアイテムを持ちます。ソートキーは複数の条件で絞り込みできるように複合キーになっています。

hkey skey ... (その他の属性)
20210101 YOYAKU#UNUSE#A#1 ...
20210101 KAKUTEI#UNUSE#A#1 ...
  • 日付が 20210101 で座標が縦=B、横=2の席で「確定」の場合
hkey skey ... (その他の属性)
20210101 YOYAKU#USE#B#2 ...
20210101 KAKUTEI#USE#B#2 ...

まずRDBやDynamoDBといったKey-Valueストアに関わらず、 基本的な原則として状態をもつアイテム(レコード)は1つにするべき です。状態が2つ以上のアイテムに分かれていると管理が大変になって、アプリケーションが複雑になります。PoCではfeatureの機能を開発、動作させてフィードバックをもらうことが主眼ですが、プロダクションに採用するアプリ開発となると別です。保守運用しやすい、追加の開発コストが低いといった、単に機能だけでなく、別の重要な観点があります。データモデルはフラットに検討して、PoCの結果に引きづられるべきではありませんでした。

弊害

この分離モデルを採用したことの一番の弊害は、状態の把握が複雑になることです。データストアで把握すべき状態が事実上2倍になっています。特にある席を予約していたが、確定時は別の席を選択した場合は、もとの「予約」の席を未使用にする。確定として選択した席を「確定」にするだけでなく、別の人からの予約の対象にならないように、「確定」した席に対して「予約」の割当を行う必要があります。本来は不要な複雑性を持ち込んでいて、アプリケーションが必要以上に複雑になりました。保守性が悪く、これは大きな反省点です。

こうすべき

状態は1つの属性(キー)にまとめるべきです。今回の場合は、状態を「空、予約、確定、着席」という状態とすれば、必要十分でした。

hkey skey ... (その他の属性)
20210101 CYAKUSEKI#A#1 ...
20210101 KAKUTEI#A#2 ...
20210101 KAKUTEI#B#1 ...
20210101 YOYAKU#B#2 ...
20210101 EMPTY#C#1 ...

ここがつらい

つらみ1:ソートキーの複合キー

DynamoDBはRDBとはデータモデルが異なります。柔軟な検索クエリを投げることができないため、RDB以上にデータモデルが重要になります。RDBであれば、検索条件の追加、別テーブルのJOINなどSQLを用いて柔軟に検索できますが、DynamoDBではクエリはソートキーに対してのみ実施できます。その他の属性はデータの絞り込みには使えません。フィルターはデータを取得したあとに、結果を落とします。

ただし、座標をキー(ハッシュキーorソートキー)に含めないとアイテムが一意にならないため、座標をキーに含める設計は必須です。かつ状態(空、予約、確定、着席)によるクエリも必須ですので、状態もキーの一部に含める必要があります。

結果として以下のようなソートキーとなりました。DynamoDBで複雑なクエリを処理しようとすると、どうしてもソートキーが複雑になってしまい、アプリケーションのコードが複雑になるのがネックですが仕方がありません。

つらみ2:トランザクション管理

DynamoDBを使う上での制約ですが、トランザクションが最大25アイテムまで、という制約がつらいです。アプリケーションとしては数百件のレコードをトランザクショナルに処理したいのですが、DynamoDBでは実現できません。結局現在のところ、アプリケーション側でバッチインサートを行って、バッチインサート中にエラーが発生した場合はロールバックする、という処理をアプリケーションのコードで実装しています。ただしロールバック中にエラーが発生する可能性があり、この場合は運用でカバーする必要があるなど運用が複雑になってしまいました。

つらみ3:ソートキーの更新

他のつらみに比べるとだいぶ細かいで小さなことですが、ソートキーは更新できないので、アイテムを一度削除して、更新後のアイテムを追加する、という操作がDynamoDBでは必要です。まぁこの程度であれば、アプリケーション側でラッパーでも挟めばいいので、そこまでつらみではないです。

つらみへの対処

結局のところ、複雑なクエリやトランザクションをDynamoDBだけで処理しようとすると、どうしても複雑性がアプリケーションに漏れ出してしまう、と感じています。冒頭ではサーバレス環境ではほぼ必然的にデータストアはDynamoDB一択と言いましたが、2021年1月現在では、RDS Proxy+Amazon Aurora( Serverless)などの選択肢も十分考えられると思います。複雑性をインフラ側にも押し込むかアプリで吸収するのか、インフラ側に一部を寄せたときの可用性、運用保守性はどうか?など考慮するポイントは多いですが、設計しがいがあるポイントです。

まとめ

  • 状態を管理するレコードはまとめよう
  • 複雑なクエリや大量データのトランザクションが必要な場合はDynamoDBだけでなく、RDS(RDB)を検討しよう

2020年の振り返りと2021年に取り組みたいこと

業務の振り返り

細かいところは公開できないので簡単に。

1月、小さい案件だけど顧客に近い立場で技術をリード。インフラ~バックエンドまで見れるスキルは役に立った。

2-4月、別チームのヘルプ、アーキテクチャは固まっていて、個別の機能開発、API開発やインフラの改善タスクがメインだった。素早く細かく対応して、チームに貢献はできたが、技術的な難易度は高くないので個人的にはまぁまぁの満足度。

5-12月、技術を中心にサーバレスで難易度が高い機能の設計、開発をやらせてもらった。サーバレスかつ技術的な難易度が高い案件でアーキ的なポジションをやらせてもらったのはとても良かった。個人のスキルで切り開いていく点も楽しいし、チームでレバレッジきかせることもできるのも楽しい。レビューだったりOJTだったり、チームメンバの育成に貢献できたのも良かった。

一方で一時期はまっていた競技プログラミングのコンテストには2021年は全く参加しなくなってしまった。水色になるまでに頑張って得た知識とか考え方は意味があったので良かったが、競技プログラミングのレートにこだわって頑張ることの価値は今の所薄いので、今後もコンテストに参加することはない気がする。

ソフトウェアエンジニア活動

Go言語を中心に、外部登壇や技術ブログ執筆など多くのアウトプットを残せた。とても良かった。会社の明示的な仕事としての外部登壇も複数あり(割愛)。

登壇(個人)

  • インライン展開をGoでのぞいてみる, golang.tokyo #28, 2019年12月.
  • ワークフローエンジンをGoで作る, Umeda.go 2020 Winter, 2020年01月.
  • ワークフローエンジンをGoで作る (再演), golang.tokyo #29, 2020年02月.
  • Go1.14のcontextは何が変わるのか, Go 1.14 Release Party in Japan, 2020年02月.
  • Introduction to JSON handling in Go, umeda.go, 2020年11月.

技術記事

会社の技術ブログにいろいろ書いた。Qiitaとかはてなブログにもいろいろ書いている。会社以外の記事の出し元は統一したのがいいのかもしれないが

  • Goの標準ライブラリのコードリーディングのすすめ, Future Tech Blog, 2020年03月.
  • Go Tips連載3: ファイルを扱うちょっとしたスクリプトをGoで書くときのTips5選, Future Tech Blog, 2020年05月.
  • 春の入門祭り 🌸 #01 Goのテストに入門してみよう!, Future Tech Blog, 2020年06月.
  • DBスキーマを駆動にした開発のためのライブラリ調査, Future Tech Blog, 2020年07月.
  • GoとSuffixArray , Future Tech Blog, 2020年08月.
  • LambdaとGoを使ったサーバーレスWebAPI開発実践入門, Future Tech Blog, 2020年09月.
  • GoでLambdaからLambdaを呼び出すときに気をつけたいポイント6選, Future Tech Blog, 2020年11月.

Qiita

Qiitaにもいろいろ書いた。「Go1.14のcontextは何が変わるのか」の記事はGoのカンファレンスの方に目をつけていただいて、登壇にもつながったので良かった。記事の質の濃淡問わず、引き続き今後も積極的に外部発信していく。

  • Goでワーカープールを15分で実装する方法, 2020年1月.
  • Go1.14のcontextは何が変わるのか, 2020年2月.
  • 改行コードって難しいっ, 2020年4月.
  • QiitaにCLIで投稿や更新できるツールを作った, 2020年4月.
  • 【初中級編】Go言語を始める方のための落とし穴、問題の解決方法やよくある間違い, 2020年10月.
  • Go言語を使ったTCPクライアントの作り方, 2020年12月.
  • AWSでのMFAをちょっと便利に扱いたい, 2020年12月.

OSS

Goでいろいろなプロダクトを作れた。自分がやりたいことを実装するのにGoが一番書きやすいので、しばらくは今後もGoを書いていくと思う。まだまだ技術力不足で到達できなかった点もあるので(d-tsuji/clipboard とか)、引き続き低レイヤーの知識も磨いていきたい。

OSSにStarをたくさんつけてもらえたのも良かった。ちなみにFindyの偏差値だと90以上あるんだけど、偏差値90とは一体なんだろう。相対的に高いのかもしれないが。

他人のプロダクトにもPRも色々出したが、機能開発とかバグFixというよりもTypoとかREADMEの指摘が多かった気がする。ただ外部のプロダクトで仕事に直結しないプロダクトの機能開発PRを出すのはなかなか優先度が上がらないので難しそう。future-architect/go-mcprotocol は会社の案件で使用しているプロダクトで、コネクションプーリング周りの機能を開発した。仕事で使うプロダクトだとPRは出しやすい。

2021年に取り組みたいこと

技術的にはアーキテクチャ選定を含む設計力を磨いていきたい。設計でしくると、実装が複雑になったり運用コストが高くなる。実装レベルのリファクタリングはテストをしっかりと書いていれば素早く対応できるが、設計レベルで失敗があると、取り返すのが難しい。大抵の場合はリファクタリングのコストがかかりすぎるので、リファクタリングできないことが多い。YAGNIの原則はもちろんそうなんだけど、設計を後回しにする、ということではない。会社の仲間から学べることも多いし、ビジネスとより向き合わないといけないし、技術書を読んでいろいろ思考していきたい。Design It!は再度読んでみるのと、Release It! 2ndを読む。

具体的には上記の一つは会社の先輩が書いた記事だが、こういう記事がかけるように観点を養っていくのと、プロジェクトで実践していく。思考&実践、フィードバックのイテレーションを回していきたい。

技術記事やOSSのアウトプット、外部登壇は2020年は豊作で良かった。2021年も引き続きアウトプットしていく。

技術を中心としたロールは変わらないけど、否応なく仕事では少なくともプロジェクトマネージメント的なロールも求められそうなので、少しずつ勉強していく。プロジェクトマネージメントも立派なスキル(暗黙的にできていることもあるだろうけど)。

その他

健康第一。30代になって、体力的にも無理がきかないので、メリハリをつけて頑張っていきたい。特に積極的に休むことが大事。また、リモートワーク中心の生活になったので、ランニングとか散歩とか積極的に取り組んだ。これは良かったと思うので2021年も継続。ちなみにコンピュータサイエンス修士号も頭の片隅にあって、JAISTの研究室とかながめていたけど、今の所あまりいきたい研究室がなかったのでひとまず2021年も後回し。

あとは囲碁エスト(9路)でレート1900を目指したい。「決定版! 囲碁 9路盤完全ガイド」を読んで筋を勉強するのと、囲碁エストで負けた棋譜をAIで解析してベターな手を検討する。

1バイトのファイルを作成する

何をしたくて書いたもの?

同僚から1バイトのファイルを作成したいんだけど、なぜか2バイトになってしまう。原因がわからないと聞かれました。OSはLinuxです。

例えば以下のようなファイルを考えてみます。

tsuji@DESKTOP-DCISGC3:/tmp/gomi$ ls -l
total 0
-rw-r--r-- 1 tsuji tsuji 2 Dec 27 20:39 hoge_1b.txt
tsuji@DESKTOP-DCISGC3:/tmp/gomi$ cat hoge_1b.txt
a

たしかに a の1文字しかファイルに書き込んでいないのですが、ls -l コマンドで確認すると2バイトと表示されています。

結論から言うと、これは改行コードによって1バイト消費しているためです。od コマンドを利用すると、以下のように調べることができます。

tsuji@DESKTOP-DCISGC3:/tmp/gomi$ od -tx1 hoge_1b.txt
0000000 61 0a
0000002

od はファイルを8進数や16進数でダンプするコマンドです。オプションがいろいろあり -tx1 とするとファイルの内容を1バイト単位で16進数で表示することができます。ファイルの中身を表示すると a(16進数で 61)の他に 0a が表示されています。これは改行コードのLFを意味します。

vim で操作する場合はデフォルトだと改行コードが付与されるので

tsuji@DESKTOP-DCISGC3:/tmp/gomi$ vim hoge_1b.txt
:set bin noeol
:w
tsuji@DESKTOP-DCISGC3:/tmp/gomi$ ls -l hoge_1b.txt
-rw-r--r-- 1 tsuji tsuji 1 Dec 27 20:48 hoge_1b.txt

などとして改行コードを追加しないようにする、などといった方法で改行コードをつけずにファイルを作成するとうまく動作します。もちろん echo -n などといった方法もあります。

tsuji@DESKTOP-DCISGC3:/tmp/gomi$ echo -n a > hoge_1b.txt
tsuji@DESKTOP-DCISGC3:/tmp/gomi$ ls -l hoge_1b.txt
-rw-r--r-- 1 tsuji tsuji 1 Dec 27 20:49 hoge_1b.txt