utamaro’s blog

誰かの役に立つ情報を発信するブログ

「会社はムダが9割」という本を読んでみた感想

本のタイトルから気になって読んでみました。

エンジニアの立場からするとムダなことを簡略化して、無くすということを考えることがあります。

その参考にしようと思って読んでみました。

感想

1回読んだときは内容が頭に入ってきませんでした。

ISO?なにそれ。そんなことよりも、ムダの改善方法や事例を教えてくれと。

「○○はムダ」という章に対して、それがなぜムダなのかがわかりやすく書かれていないこと、どのような基準で「ムダ」といっているのかが分からなかったからです。

例えば「社員同士で遠慮するムダ」という章があるのですが、最後の方でようやく「遠慮を無くすことで仕事が上手く回り始めた」といった記載がありました。

ただ、それも結局「仕事をどんどん振って現場に連れて行く」ということをやるようにアドバイスをしたからと読み取れました。

定価で買うだけの価値はないかな?
という感想がはじめに来ました。中古本かKindleUnlimitedで0円で読むのが良いと思います。

分かりづらい部分もありますが、参考になる箇所もあります。

話の中には実は自分もやっているかなと思うものがありました。

例えば「実行しないムダ」「自分のことがわからないムダ」という章があります。

前者は計画を立てただけでは意味が無く、走りながら修正する。

実際に運用しながら微調整を加えて精度を上げるというものです。

後者は自分の性質を知るということ。

何をしたいのか、何が好きなのか、何が嫌いなのか。

現状分析を行い、ゴールから逆算して計画を見直すことで結果が出せるというものです。

まとめ

私が「会社はムダが9割」という本で得られたことは、会社内のムダを知るというよりも、自分のための気づきでした。

今の自分が何を改善すればよいのかという指標を得るためのツールとして、この本を使うのが良いと思います。

DjangoでPageNotFound時にjsonを返す方法

Djangoapiを作っている際に、自分で定義していないpathをリクエストしたところ404ページが表示されました。

apiを使うため、これをjsonで返すように変更したいと考えて、実現する方法を調べてみました。

参考にしたページ一覧

Error handling
https://docs.djangoproject.com/en/3.0/topics/http/urls/#error-handling

handler404
https://docs.djangoproject.com/en/3.0/ref/urls/#handler404

Customizing error views
https://docs.djangoproject.com/en/3.0/topics/http/views/#customizing-error-views

実現方法

まずはproject/urls.pyを以下のようにします。

from django.http import JsonResponse
from django.urls import path, include


def handle_404(request, exception):
    return JsonResponse({"status": "page not found."})

def handle_403(request, exception):
    return JsonResponse({"status": "forbidden."})

def handle_500(request):
    return JsonResponse({"status": "server error."})


urlpatterns = [
    path('api/', include('api.urls')),
]

handler403 = 'project.urls.handle_403'
handler404 = 'project.urls.handle_404'
handler500 = 'project.urls.handle_500'

この記述の中のhandlerXXXが重要な部分です。

この記述はurls.pyに書くようにドキュメントにかかれています。 また、参考にできるコードはdjango.views.defaults.page_not_foundから確認できます。

import用のパスをproject.urls.handle_403のようにしていますが、これは以下のように関数を代入しても良いです。

handler403 = handle_403
handler404 = handle_404
handler500 = handle_500

handle_404関数についてはurls.pyに書く必要はなく、別のファイルに書いても大丈夫です。

例えば、restframework用の例外ハンドラと同じ場所に書くのが良いと思います。

検証

検証する際にDEBUG = TRUEではできないのでFALSEにします。

curlを利用してリクエストします。

curl http://localhost:8000/api/hoge/piyo

結果

{"status": "page not found."}

このときのexception変数のインスタンスResolver404でした。 404でハンドリングしているため、Http404で例外が出ていると予想していたのですが間違っていました。

というかdocstringに以下のように書かれていたのでわかっていませんでした。

The message from the exception which triggered the 404 (if one was supplied), or the exception class name

なんのインスタンスなのか書いておいてほしいです。

Resolver404だったので、api内部でraise Http404としてもハンドラでキャッチされませんでした。

私の場合はdjangorestframework用の例外ハンドラを用意していたので、そちらでキャッチされました。

alembicで環境別にマイグレーションファイルを分割する

alembicというマイグレーション管理ライブラリを利用したマイグレーションファイルの分割方法について。

実現したいのは、開発環境のrevisionと本番環境用のrevisionを分けたいということです。

分けることで、リリースタイミングですべてのテストが完了したrevisionのみ作成されて、マイグレーションファイルをきれいに管理できるようにするためです。

また、本番リリースのタイミングで開発環境上のマイグレーションを破棄して、本番用のバージョンに置き換えるということもできます。

最終的なディレクトリ構成は以下のようになります。

.
├── alembic.dev.ini
├── alembic.prod.ini
├── app
│   ├── __init__.py
│   └── models
│       ├── __init__.py
│       └── users.py
├── app.py
├── db
│   ├── __init__.py
│   ├── migrate_dev
│   │   ├── README
│   │   ├── env.py
│   │   ├── script.py.mako
│   │   └── versions
│   │       ├── 2020_02_23_123853_.py
│   │       ├── 2020_02_23_125416_.py
│   │       └── __init__.py
│   └── migrate_prod
│       ├── README
│       ├── env.py
│       ├── script.py.mako
│       └── versions
│           ├── 2020_02_23_125959_.py
│           └── __init__.py
└── static

準備

依存ライブラリ

SQLAlchemy==1.3.13
alembic==1.4.0
mysqlclient==1.4.6

pythonのバージョン

Python 3.8.0

環境毎に初期化を行う

※ 一つ一つメモを取りつつ行ったわけではないため、コマンド実行時の結果に差がでる可能性があります。ご参考程度によろしくおねがいします。

initコマンド使用して初期化します。

alembic init db/migrate_dev

このとき、カレントディレクトリ内にalembic.iniというファイルが作成されます。 このファイルをalembic.dev.iniにリネームします。

mv alembic.ini alembic.dev.ini
alembic init db/migrate_prod

こちらも同様にalembic.prod.iniにリネームしてください。

mv alembic.ini alembic.prod.ini

環境別のenvを修正する

devprodenv.pyは同じことを書いてください。

import importlib
import os
import sys
from logging.config import fileConfig

from sqlalchemy import engine_from_config, MetaData
from sqlalchemy import pool

from alembic import context

config = context.config

fileConfig(config.config_file_name)

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_ROOT = BASE_DIR + '/../app/models'
sys.path.append(MODELS_ROOT)

target_models = [
    "users",
]


class BaseEnv:
    @staticmethod
    def make_target_metadata():
        lst = list(map(lambda x: importlib.import_module(x).Base.metadata, target_models))
        m = MetaData()
        for metadata in lst:
            for t in metadata.tables.values():
                t.tometadata(m)
        return m


def run_migrations_online():
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """

    alembic_config = config.get_section(config.config_ini_section)
    connectable = engine_from_config(
        alembic_config,
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    target_metadata = BaseEnv.make_target_metadata()
    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            compare_type=True,
        )

        with context.begin_transaction():
            context.run_migrations()


run_migrations_online()

今回はrevision --autogenerateで自動的にモデルを読み込むためにBaseEnv.make_target_metadata()を作成しました。

残念ながらenv.pyが実行されるタイミングがalembic revisionが実行されたタイミングなのでapp以下のmodelsを読み込むのが難しいです。

そのために、以下のようにモデルまでのパスを登録しています。

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_ROOT = BASE_DIR + '/../app/models'
sys.path.append(MODELS_ROOT)

これを行うことで、以下のようにモデルを定義できます。

target_models = [
    "users",
]

最後にメタデータを取得するための関数を通して、target_metadataを作成しています。

usersテーブル用のモデルを作成する

よくある書き方なので詳細については省略します。

from sqlalchemy import DATETIME, Column, Integer, String, BigInteger
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    id = Column(BigInteger, primary_key=True, nullable=False)
    name = Column(String(255), nullable=False)
    email = Column(String(255), nullable=False)
    created_at = Column(DATETIME, nullable=False)
    updated_at = Column(DATETIME, nullable=False)

.iniファイルを修正する

initで作成されたファイルのうち、[alembic]を以下のように修正します。

[alembic]
script_location = db/migrate_dev
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
sqlalchemy.url = mysql://root:admin@localhost:3306/sample_a

※ sqlalchemyとmysqlclientを利用しているため、sqlalchemy.urlは適宜読み替えてください。

同様にalembic.prod.iniも修正します。

[alembic]
script_location = db/migrate_prod
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
sqlalchemy.url = mysql://root:admin@localhost:3306/sample_b

今回はわかりやすく別スキーマを用意して、マイグレーション対象のDBを分けました。

これで、alembic.dev.iniの場合はsample_aDBに対してマイグレーションが実行され、alembic.prod.iniの場合はsample_bDBに対して実行されるようになります。

revisionを作成する

revisionを作成する際に以下のことが必要です。

  1. .iniを指定する。
  2. modelsから自動的に読み込む。

これを以下のコマンドで実現します。

alembic -c ./alembic.prod.ini revision --autogenerate

実行後にdb/migrate_prod/versions2020_02_23_123612_.pyというファイルが作成されます。

その中が以下のようになっていれば成功です。

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('users',
    sa.Column('id', sa.BigInteger(), nullable=False),
    sa.Column('name', sa.String(length=255), nullable=False),
    sa.Column('email', sa.String(length=255), nullable=False),
    sa.Column('created_at', sa.DATETIME(), nullable=False),
    sa.Column('updated_at', sa.DATETIME(), nullable=False),
    sa.PrimaryKeyConstraint('id')
    )
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('users')
    # ### end Alembic commands ###

同様にdevも実行してみてください。

alembic -c ./alembic.dev.ini revision --autogenerate

DBをマイグレーションする

作成された2020_02_23_123612_.pyをDBに反映させます。

alembic -c alembic.dev.ini upgrade head

これを実行すると、sample_aスキーマusersテーブルが生成され、alembic_versionテーブルにRevision IDが追加されます。

devで作業して、成果物をprodに反映する

モデルを修正して、スキーママイグレーションします。

今回はUserモデルにdeletedを追加します。

deleted = Column(Boolean, default=False)

修正分のリビジョンを作成します。

alembic -c ./alembic.dev.ini revision --autogenerate

2020_02_23_125416_.pyが作成されて、以下のスクリプトが生成されました。

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.add_column('users', sa.Column('deleted', sa.Boolean(), nullable=True))
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_column('users', 'deleted')
    # ### end Alembic commands ###

マイグレーションします。

alembic -c alembic.dev.ini upgrade head

これでdeletedカラムが追加されます。

この段階でdb/migrate_dev/versionsの中身は以下のようになっています。

db/migrate_dev
├── README
├── env.py
├── script.py.mako
└── versions
    ├── 2020_02_23_123853_.py
    ├── 2020_02_23_125416_.py
    └── __init__.py

この段階でdb/migrate_prod/versionsの中身は以下のようになっています。

(migrate_prodversionsに複数のファイルが含まれておらず、きれいなままです。)

db/migrate_prod
├── README
├── env.py
├── script.py.mako
└── versions
    └── __init__.py

usersテーブルの成果物を本番環境に反映します。

alembic -c ./alembic.prod.ini revision --autogenerate

以下のようにスクリプトが生成されます。

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('users',
    sa.Column('id', sa.BigInteger(), nullable=False),
    sa.Column('name', sa.String(length=255), nullable=False),
    sa.Column('email', sa.String(length=255), nullable=False),
    sa.Column('deleted', sa.Boolean(), nullable=True),
    sa.Column('created_at', sa.DATETIME(), nullable=False),
    sa.Column('updated_at', sa.DATETIME(), nullable=False),
    sa.PrimaryKeyConstraint('id')
    )
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('users')
    # ### end Alembic commands ###

あとはマイグレーションするだけです。

alembic -c alembic.prod.ini upgrade head

これでsample_bスキーマusersテーブルが生成されました。

参考

https://alembic.sqlalchemy.org/en/latest/tutorial.html

http://okerra.hatenablog.com/entry/2020/02/05/184237

Flaskで共通的なpath変数を検証する方法

/api/<lang_code>/sampleのようなurlが用意されている場合、lang_codeはすべてのurlで利用する場合を想定します。

このとき、controller側でパタメータを取得し、検証する方法もあります。

ただし、すべてのcontrollerで同じ処理を書いてしまうのは避けるべきことです。

また、うっかり実装を忘れてしまうこともあります。

apiを用意する

class basedapiを用意しています。

本題と逸れる実装については解説を省きます。

今回はSampleApiを用意しました。

class SampleApi(ApiResource):
    endpoint = 'SampleApi'
    url_prefix = '/api/<lang_code>/sample'
    methods = ['POST']
    url_rules = [
        UrlRule(
            endpoint='sample',
            rule='index',
            methods=['POST'],
        )
    ]

    def post(self, lang_code):
        body = request.json
        print("called sample api.", body)
        return jsonify(body)

このapicurlを使って実行すると以下のようになります。

curl -X POST -H "Content-Type: application/json" -d '{
    "title": "sample title"
}' http://127.0.0.1:8080/api/ja/sign-in/index

このときの/ja/の部分を取得して、DBからデータを取得するなどできるようにします。

他にも、アプリケーションコンテキスト中にデータを格納できるgに値を入れるなどできます。

url_value_preprocessorの設定を行う

Flaskを拡張して、独自の設定を追加できるclassを作成します。

from app.request_module.pre_processors import language_code

class AppFlask(Flask):
    """Extended version of `Flask` that implements custom config class"""

    def init_url_value_preprocessor(self):
        self.url_value_preprocessor(language_code)

url_value_preprocessorlanguage_codeという関数を設定しています。

url_value_preprocessorFlask.before_request(f)の後に実行されます。

https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.url_value_preprocessor

language_code関数を用意する

url_value_preprocessorで登録する関数にはendpointvaluesが必要です。

def language_code(endpoint, values):
    api_class = url_for(endpoint, lang_code="ja")
    if 'lang_code' in values or not g.lang_code:
        return

今回のSampleApiを実行した場合、endpointvaluesにはそれぞれ以下のようになります。

endpoint: SampleApi.sample
values:
  lang_code: ja

yamlで書いていますが、yamlが値に入るわけではありません。

endpointurl_forで使用可能な値です。

あとはapiを実行するだけで、先程作成したlanguage_code関数が実行されます。

material-iconsをchrome拡張のcontent-scriptで使用する方法

環境を整える

開発環境を整えます。

今回使うライブラリは以下のものを使います。

"dependencies": {
  "css-loader": "^2.1.1",
  "file-loader": "^3.0.1",
  "jquery": "^3.4.1",
  "material-icons": "^0.3.1",
  "webpack": "^4.32.2",
  "sass-loader": "^7.1.0",
  "style-loader": "^0.23.1",
  "ts-loader": "^6.2.1",
  "typescript": "^3.7.3",
  "url-loader": "^1.1.2",
}

他にも依存関係を使っているのですが、細かいところは省いています。

webpackを使ってtypescriptで書かれたスクリプトをビルドして、それを拡張機能として利用します。

また、content-scriptを使って画面を拡張してmaterial-iconsのアイコンを表示します。

webpackの設定を行う

module.exports = {
  module: {
    rules: [
      {
        test: /\.woff2?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
        use: [
          {
            loader: 'url-loader?limit=10000&name=../fonts/[name].[ext]',
          }
        ]
      },
      {
        test: /\.(ttf|eot|svg)(\?[\s\S]+)?$/,
        use: [
          {
            loader: 'file-loader?name=../fonts/[name].[ext]',
          }
        ]
      },
    ]
  }
}

ここで大事なのはloaderの部分です。

nameを指定することで出力時のwoffファイルがfonts/MaterialIcons-Regular.woffとのようになります。

出力時のpathはwebpack.configのあるディレクトリがルートとなります。

これを行わない場合、ファイル名がhashとなります。

そうすると、画面上でwoff等を読み込むのが難しくなります。 ※ build時に再度生成されるため、毎回書き直す必要が出てきます。

manifest.jsonを設定する

{
  "content_scripts": [
    {
      "run_at": "document_end",
      "matches": ["*://*/*"],
      "js": [ 
        "./assets/js/content.bundle.js"
      ]
    }
  ],
  "web_accessible_resources": [
    "assets/fonts/MaterialIcons-Regular.eot",
    "assets/fonts/MaterialIcons-Regular.ttf",
    "assets/fonts/MaterialIcons-Regular.woff",
    "assets/fonts/MaterialIcons-Regular.woff2"
  ],
}

このようになります。

ここで重要なのは、web_accessible_resourcesにフォント用のファイルを書いていることです。

(ワイルドカード*を使っても良いですが、使えるリソースを把握するのにすべて書いたほうが良いと考えてこのようにしています。)

これを行うことで、画面上からリソースを使用することができます。

このパラメータについてはManifest - Web Accessible Resourcesを参照するのが良いです。

リソースを扱う場合は以下のようにurlを書かなければなりません。

chrome-extension://[PACKAGE ID]/[PATH]

web_accessible_resourcesを使わない場合material-iconsで使用するwoffなどが以下のように読み込まれます。

https://[domain]/~.woff

もちろんそのページで.woffを使用していない限り、これはエラー(404)となります。

もう一点重要な点があります。

"assets/fonts/MaterialIcons-Regular.eot",

リソースの書き方です。

最初は以下のように相対パスで実装していました。

{
  "web_accessible_resources": [
    "./assets/fonts/MaterialIcons-Regular.eot",
  ]
}

これは動きませんでした。

原因はわかりませんでした。

予想ではリソースの読み込み時に利用するurlが異なるからです。

画面に@font-faceを埋め込む

manifestの設定時に解説した通り、リソースを利用する場合は以下のように使用する必要があります。

chrome-extension://[PACKAGE ID]/[PATH]

このパスを作成するのに必要なのが、拡張機能のIDです。

IDは開発時と配信時に別のIDが付けられるため、ハードコーディングでは対応できません。

googleはこのurlを作成するための機能を用意しています。

以下を使用します。

chrome.extension.getURL("");

getURL("hoge.png")を利用することでchrome-extension://[PACKAGE ID]/hoge.pngとうurlを取得できます。

以上を踏まえて、画面上に@font-face<style>として埋め込みます。

function makeFontFace() {
  let woff2 = chrome.extension.getURL("assets/fonts/MaterialIcons-Regular.woff2");
  let woff = chrome.extension.getURL("assets/fonts/MaterialIcons-Regular.woff2");
  let ttf = chrome.extension.getURL("assets/fonts/MaterialIcons-Regular.woff2");
  let newStyle = document.createElement('style');
  newStyle.textContent = '\
  @font-face {\
      font-family: "Material Icons TestExtension";\
      font-style: normal;\
      src: url("' + woff2 + '") format("woff2"),\
        url("' + woff + '") format("woff"),\
        url("' + ttf + '") format("truetype");\
  }'
  document.head.appendChild(newStyle);
}

これで画面上からリソースを利用できるようになります。

jquery使ってないというツッコミはなしでお願いします。

ここで重要なのはfont-familyの値として、Material Icons TestExtensionとしていることです。

もし画面上でMaterial Iconsを使っていた場合、コンフリクトが発生する可能性があります。

あとはこの関数を実行するだけです。

cssを追加する

私の場合はscssを使っています。が、sassの場合は読み替えてください。

.test-extension {
  .material-icons {
    font-family: "Material Icons TestExtension";
    font-weight: normal;
    font-style: normal;
    font-size: 24px;
    display: inline-block;
    line-height: 1;
    text-transform: none;
    letter-spacing: normal;
    word-wrap: normal;
    white-space: nowrap;
    direction: ltr;
    /* Support for all WebKit browsers. */
    -webkit-font-smoothing: antialiased;
    /* Support for Safari and Chrome. */
    text-rendering: optimizeLegibility;
    /* Support for Firefox. */
    -moz-osx-font-smoothing: grayscale;
    /* Support for IE. */
    font-feature-settings: 'liga';
  }
}

重要なのは、Material Iconsに書かれているように以下のように使用しないことです。

$material-icons-font-path: '~material-icons/iconfont/';
@import '~material-icons/iconfont/material-icons.scss';

このようにすると、せっかく参照できるようにしたリソースファイルを利用してくれません。

ちなみに、上記のスタイルは以下のファイルを参考にしました。

node_modules/material-icons/iconfont/material-icons.css

まとめ

やることは以下のことです。

  • リソースを登録する
  • @font-faceを埋め込む
  • material-iconsのスタイルを独自に用意する

以上です。

これを解決するのに1日かかってしまいました。

だれかの参考になれば幸いです。

リファクタリングを行うタイミングについて

最近リファクタリングについて考える機会がありました。

今回はリファクタリングを実施するタイミングについて、これまで経験したことを記事にしたいと思います。

記事の流れとしては以下のように書きたいと思います。

  1. リファクタリングを行う理由
  2. リファクタリングのタイミングについて
  3. 全体的な開発の流れ

1. リファクタリングを行う理由

リファクタリングを行うことで、内部構造を改善して理解や修正が簡単になります。

この考えは長く開発をしている方はよく理解しているかと思います。

私がこれまで経験した一番悪いケースを紹介すると「10年間リファクタリングをしていないため、重複箇所が多く影響調査に1週間かかる」というものでした。

10年間というキーワードが出てきたことでお気づきかと思いますが、古いシステムです。 PHPを使って作ったシステムで、フレームワークも当時のものを使っています。

控えめに言ってやばいです。

これが原因で上(社長等)から「もっと成果を上げろ」と言われます。

成果を上げるためには重複箇所を修正しなければなりません。 しかし、その修正が目に見えるような成果として出ないものなので、上がなかなか納得してくれません。 説得するための資料を作成するという無駄に時間のかかる作業が必要になり、ベテランのエンジニアが転職していきます。 最終的に、プロダクトを深く理解している人がいなくなり誰も触れないプログラムが出来上がりました。

こうなってしまうと、要件定義から始めて作り直す方が良いかもしれません。

より長く製品を維持するためにリファクタリングが必要になってきます。

2. リファクタリングのタイミングについて

同じコードを2回書いた時に行う

3度目の法則というものがあるのですが、私は2回目で修正を行うべきだと考えています。

経験上、2度同じコードを書いたものは、また別の箇所で同じコードを書いてしまいます。 そして、3つ同じコードが書かれることで修正範囲が広くなってしまい、「修正しよう」という意思が萎えます。 そして重複コードが量産され、作り終わって時間が出来たら直そうという考えに至ります。 そして、その未来はやって来ません。 そうならないように2度目で修正を行います。

修正する際には機能追加や機能改善は行わないようにします。 同時に進めてしまうと、結果の検証時に振る舞いが変わっていないかか確認しづらくなります。

プログラムに手を加えた後で行う

これは多くのチームでやっていないことかと思います。これまで4つのチームで開発を行い全てでやっていませんでした。 機能追加や機能改善、バグフィックス等を行ったあとに、リリース作業を行って終了というケースです。

リリース前にリファクタリング期間を入れます。

手を加えたコードに対して重複箇所をなくしたり、仕方がなく冗長な書き方をしている箇所にコメントを入れたり。 デザインテンプレートを適用して、予想できる機能追加に対して手を打っておきます。

これを行うことによって、製品の品質が維持・向上できます。

リリース前に行う

リリース後のリファクタリングには苦労します。

利用者に影響が出ないように慎重に行う必要がありますし、万が一問題が出たら詫び石を配布しなければならないかもしれません。

twitterで謝罪したり、原因調査等行わないとならないです。

正直面倒です。

そうならないように、リリース前に一度リファクタリングします。

3. 全体的な開発の流れ

リファクタリング期間を入れた際の大まかな開発の流れです。

  1. 設計
  2. 仮実装(要件は満たす状態)
  3. 実装(クラス構造を見直したりする)
  4. テスト作成
  5. リファクタリング(既存のコードと設計を合わせたりする)
  6. リリース or 次の開発

業務では(上からの圧力により)このような流れでできていないのですが、個人開発ではこのように進めています。

がむしゃらに進むより速度は遅いですが、スムーズに進みます。

イメージとしては3歩進んで1回休憩という感じです。 休憩中に、もっと良い方法など考えます。 歩き方が効率化されて、目的地まで疲れずに辿り着けます。

属性付きセレクタを作成する関数を作った。

属性付きセレクタを作成する関数を作った。

jqueryである属性がついたタグを取得したいと思ったときに、セレクタをハードコーディングすることがあります。

例えば以下のようなものです。

$("div[id='hoge']")

hogeという値が動的に指定できる場合はもう少し面倒になります。

$("div[id='"+ val +"']")

たまに事故が起きるのは'が抜けていたり、"で囲えていなくてビルドがエラーになったりします。

今回はこういうことが無いように便利なutilを作りました。

Attrクラスを作成する

このクラスを使って属性指定します。

export class Attr {
  private key: string
  private val: string

  constructor(key: string, val: string) {
    this.key = key
    this.val = val
  }

  getKey() { return this.key }
  getVal() { return this.val }
}

ちなみに、僕はconstructorに引数が2つの場合はsetterを作らないようにしてます。

3つ以上になったらsetterを作成します。

属性付きセレクタを生成するためのutilを作成する

Attrクラスをimportしています。

今回はmakeAttrSelectorという関数を作りました。

import {Attr} from './Attr'

export var SelectorUtils = {
  makeAttrSelector: function(target: string, attrs?: Array<Attr>) {
    if (attrs == void(0)) {
      return target
    }
    let selector = target + (function(attrs: Array<Attr>): string {
      let result = ""
      attrs.forEach((attr: Attr, index, array) => {
        result += "[" + attr.getKey() + "='" + attr.getVal() + "']"
      })
      return result
    })(attrs)
    return selector
  }
}

属性の指定がない場合にも対応可能なように、attrs引数はオプションとしています。

このメソッドを実行すると以下のようになります。

let selector = SelectorUtils.tagAttrs(
  "#test", 
  [new Attr("A", "A"), new Attr("B", "B")]
)
console.debug(selector)

結果

#test[A='A'][B='B']

new Attr("A", "A")が面倒に思えるかもしれません。 ただし、[{key: A, val: A}, {key: B, val: B}]という方法に変えた場合、タイプミスが発生する可能性が高いです。 このような理由から今回はclassを使いました。

テスト

便利なutilを作成した場合は必ずテストを書きましょう。

多くの場所で使われることが想定できるので、誰かが修正した場合に品質を担保する必要があります。

今回はjestを使用してテストを作成しました。

import {SelectorUtils} from '../SelectorUtils'
import {Attr} from '../Attr'

describe('test', () => {
  let idAttr = "#test"

  test.each([
    [null, idAttr],
    [[new Attr("A", "A")], idAttr + "[A='A']"],
    [[new Attr("A", "A"), new Attr("B", "B")], idAttr + "[A='A'][B='B']"]
  ])("属性付きセレクタの生成テスト", (attrs, expected) => {
    let selector = SelectorUtils.tagAttrs(idAttr, attrs)
    console.log({
      actual: selector,
      expected: expected,
      args: [attrs]
    })
    expect(selector).toEqual(expected)
  })

  test("attrs引数を指定しない場合", () => {
    let selector = SelectorUtils.tagAttrs("#test")
    expect(selector).toEqual("#test")
  })
});

テストにログを出すのはなぜかと聞かれたことがあります。

私の場合は以下の理由から出しています。

  • テスト対象の関数が変更されたときにテストを修正しやすくするため