~fvknk/bk

技術関連の備忘録

はじめての OSS コントリビュート

概要

先日、はじめて OSS のリポジトリに向けて Pull Request を作成して、マージされました。

内容はドキュメント修正なので、直接技術的な貢献をしたわけではありませんが、せっかくなので記録として残しておこうと思います。

  • 対象リポジトリ

github.com

  • Pull Request

github.com

きっかけ

別の調べ物のために Rails ガイドを読んでいたところ、存在しないコードに言及している文章があることに気がつきました。

原文、および以前のバージョンの原文を確認したところ、以前原文に存在していた文の訳が残っているということが推測できました。

そこで、日本語版 Rails ガイドコンテンツの末尾の「本ガイドを読んで気になる文章や間違ったコードを見かけたら、気軽に Pull Request を出して頂けると嬉しいです。」という文言に背中を押されて Pull Request を出してみることにしました。

やったこと

基本的に railsguides.jp リポジトリの「README > フィードバックについて > ブラウザでRailsガイドの修正を提案する (オススメ)」をベースに修正と Pull Request の提出をしました。

github.com

注意

以下は2024年8月時点に railsguides.jp へ向けて行った方法です。最新の手順、および他リポジトリでの手順は都度対象リポジトリをご確認ください。

1. GitHub で対象のファイルを開く

Rails ガイド の該当ページコンテンツ末尾の「GitHub で編集を提案する」リンクからで対象ファイルを開きました。

念のため、リポジトリに記載されているディレクトリと一致していることを確認しました。

2. ペンアイコンをクリックして、リポジトリをフォークする

画面右上のペンアイコンをクリックすると、以下のような文章とボタンが表示されるので、「Fork this repository」をクリックしました。

3. 修正して、コミットする

エディターが表示されるので、修正をして右上の「Commit changes…」ボタンをクリックしました。

「Commit changes…」ボタンをクリックすると、以下のようなフォームが出てきたので、修正内容を表すタイトルをつけて「Propose changes」をクリックしました。

この時点で、フォークしたリポジトリで patch-1 のような名前でブランチを切って、変更内容をコミット・プッシュしているようです。

4. 意図しない差分を削除する

「Propose changes」をクリックすると、差分を確認できるページに遷移しました。

差分を確認すると、末尾に不要な差分が発生していました。

そのため、一度ローカルにフォークしたリポジトリをクローンして、念のため、「3. 修正してコミットする」で作成したブランチとは別にブランチを切ってから、差分が適切になるように調整・プッシュしました。

再度フォーク元のリポジトリに移動すると、最後にプッシュしたブランチから Pull Request を作成できるようになっていました。

Pull Request 作成前の差分を確認すると、意図する差分になっていたため、修正内容を補足するメッセージを書いてから「Create pull request」を押して、Pull Request を作成しました。

github.com

Pull Request のメッセージは外部からリクエストされてそうな数件を確認して、おおよそ似たような温度感で書いておきました。

レスポンス・マージを待つ

自分がやれることはやったので、レスポンスかマージを待ちました。

再修正や確認が必要であれば追加の作業が発生するかと思うのですが、今回はなかったのでそのままマージとなりました。

良かったこと

はじめての OSS コントリビュートとして、今回の経験は自分にとってちょうど良いハードルだったように思いました。

おそらく以下3点によりハードル下がったのではないかと思いました。

  • 日本語で修正方法が記載されていたため、操作にほぼ迷わなかった
  • コードの修正や内容の追記・編集ではなかったため、自分で修正内容の妥当性を検証しやすかった
  • 過去の Pull Request の雰囲気から、本当に気軽に送ってよさそうという自信を得られた

まとめ

はじめて OSS にコントリビュートしてみました。 ドキュメントというと「もの凄く強いエンジニアが作ってくれた文書」というイメージがあり、私自身が貢献することはないと思っていたため、後から確認して自分のコミットが乗っかっていることに、不思議な気持ちになりました。 修正内容によるかとは思いますが、ものによっては想像よりハードルが高くはないことがわかったので、今後も機会があれば、何らかの形でコントリビュートできればと思いました。

VSCode で変数名・メソッド名などを翻訳する拡張機能を作る

はじめに

VSCode 上で変数名やメソッド名を翻訳する拡張機能を作成しました。

今回ははじめて拡張機能を作成したため、その備忘録的な内容です。

解決したかったこと

コード内の変数名で馴染みのない英単語が用いられている場合に、以下の手順を踏むことが面倒くさく感じていました。

  1. 翻訳サイトを開く
  2. コード上の変数名をコピーして、翻訳サイトにペーストする
  3. 必要に応じて、ペーストした変数名から不要な記号を削除して、空白で分書する
    • 分書が不要なケースもありますが、うまく翻訳できないケースにも何度か遭遇していました
  4. 翻訳を実行する

翻訳までの手順の多さや煩わしさを解消するために、(ある程度信頼できそう、かつ公式テキストが読めそうな中で)拡張機能を検索したのですが、イマイチ要求を満たすものを見つけられませんでした。

  • Vscode Google Translate
    • インストール数は多くある程度信頼できそうだが、機能が要求を満たさない
  • Comment Translate
    • 求めるものに近そうだが、翻訳に中華系サービスを使っているのが個人的に少し怖い

そこで以下を満たすように VSCode 上で動作する拡張機能を自作しました。

  • 開くアプリケーションは VSCode のみでよい(別途ブラウザを開かなくて良い)
  • コピーアンドペースト不要
  • 分書不要

作ったもの

変数名やメソッド名の文字列を日本語に翻訳して表示する VSCode の拡張機能を作成しました。

文字列を選択した状態でコンテキストメニューを開くと専用のメニューが表示され、メニューを選択すると翻訳結果が表示されるという機能を提供しています。

以下ではキャメルケースで書かれた文章helloWorld を翻訳しています。

リポジトリ

github.com

事前準備

VSCode の拡張機能の作成に関する基本的な情報はこちらにまとまっていたので、以下を参考にしました。

code.visualstudio.com

Hello World アプリを作成する

まずは、チュートリアルの手順を追いながら、サンプルとなる拡張機能を作成してみました。

code.visualstudio.com

最初に YeomanVS Code Extension Generator を使って、プロジェクトを準備しました。
指定のコマンドを実行すると、対話形式でいくつか質問をされたので、順に回答してプロジェクトを作成しました。

$ npx --package yo --package generator-code -- yo code

     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? variable-translator
? What's the identifier of your extension? variable-translator
? What's the description of your extension? 
? Initialize a git repository? Yes
? Which bundler to use? unbundled
? Which package manager to use? npm

Writing in /Users/***/variable-translator...
   create variable-translator/.vscode/extensions.json
   create variable-translator/.vscode/launch.json
   create variable-translator/.vscode/settings.json
   create variable-translator/.vscode/tasks.json
   create variable-translator/package.json
   create variable-translator/tsconfig.json
   create variable-translator/.vscodeignore
   create variable-translator/vsc-extension-quickstart.md
   create variable-translator/.gitignore
   create variable-translator/README.md
   create variable-translator/CHANGELOG.md
   create variable-translator/src/extension.ts
   create variable-translator/src/test/extension.test.ts
   create variable-translator/.vscode-test.mjs
   create variable-translator/eslint.config.mjs

Changes to package.json were detected.

Running npm install for you to install the required dependencies.
npm warn deprecated [email protected]: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm warn deprecated [email protected]: Glob versions prior to v9 are no longer supported
npm warn deprecated [email protected]: Glob versions prior to v9 are no longer supported

added 266 packages, and audited 267 packages in 28s

69 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Your extension variable-translator has been created!

To start editing with Visual Studio Code, use the following commands:

     code variable-translator

Open vsc-extension-quickstart.md inside the new extension for further instructions
on how to modify, test and publish your extension.

For more information, also visit http://code.visualstudio.com and follow us @code.

? Do you want to open the new folder with Visual Studio Code? Skip

(本筋に関係はないですが、CLI でのプロジェクト作成の段階で上のような AA で表現されたオリジナルキャラクターのようなものが出てくると、少しワクワクします。)

コマンド実行後、プロジェクトのディレクトリを VSCode で開き、Cmd + Shift + p でコマンドパレットを開きました。 開いたコマンドパレットには Debug: Start Debugging と入力してデバッグを開始しました。

その後、続けて「VS Code 拡張機能の開発」を選択して、別ウィンドウで開かれた拡張機能開発ホスト用のウィンドウで Cmd + Shift + p でコマンドパレットを開きました。 開いたコマンドパレットに Hello World と入力すると、画面右下に「Hello World from variable-translator!」と表示されました。

サンプルを改変して翻訳機能を作る

今回は以下の操作で利用できる機能を作成することを目標にしました。

  1. 文字列を範囲選択する
  2. 右クリックで翻訳を選択
  3. 翻訳結果を画面に表示

文字列を画面に表示する動作はサンプルの時点でできているので、まずは翻訳機能を呼び出すことを目指しました。

0. 翻訳 API を作成する

まずは、https://qiita.com/tanabee/items/c79c5c28ba0537112922 を参考に、Google App Script(以下 GAS)で翻訳 API を作成しました。

今回は英語から日本語に翻訳するだけなので、翻訳元・翻訳先は固定し、翻訳対象の文字列のみを入力して翻訳結果を返却するようにしました。

function doGet(e) {
  const params = e.parameter
  const translatedText = LanguageApp.translate(params.text, 'en', 'ja')

  return ContentService.createTextOutput(translatedText)
}

API を Web アプリとしてデプロイしたら、以下のコマンドを実行して実行できることを確認しました。

$ curl -L {Web アプリ URL}\?text\=Hello
こんにちは

API からのレスポンスは JSON として処理したいため、以下を参考に GAS のコードを修正しました。

qiita.com

function doGet(e) {
  const params = e.parameter
  const translatedText = LanguageApp.translate(params.text, 'en', 'ja')

  const json = JSON.stringify({text: translatedText})

  let output = ContentService.createTextOutput()
  output.setMimeType(ContentService.MimeType.JSON)
  output.setContent(json)
  return output;
}

修正後、再度デプロイして curl コマンドで実行確認したところ、JSON 形式でレスポンスが返却されることを確認できました。

$ curl -L {Web アプリ URL}\?text\=Hello | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   584    0   584    0     0    567      0 --:--:--  0:00:01 --:--:--   568
100    26    0    26    0     0     17      0 --:--:--  0:00:01 --:--:--     0
{
  "text": "こんにちは"
}

1. 拡張機能から GET リクエストを送信する

まずは、拡張機能から翻訳 API にリクエストを正常に送信して、レスポンスを正常に受け取れるようにするため、src/extension.ts を以下のように編集しました。

import * as vscode from 'vscode'

interface response {
  text: string
}

export async function activate(context: vscode.ExtensionContext) {
  const text = 'Hello'
  const url = `{Web アプリ URL}?text=${text}`
  const res = await fetch(url).then(data => data.json()) as response

  const disposable = vscode.commands.registerCommand('variable-translator.helloWorld', () => {
    vscode.window.showInformationMessage(`from: ${text} -> to: ${res.text}`)
  })

  context.subscriptions.push(disposable)
}

export function deactivate() { }

リロード後に、再び拡張機能開発ホスト用のウィンドウでコマンドパレットを開いて Hello World と入力すると、画面右下に翻訳結果が表示されるようになりました。

このままではテストがしにくいため、最終的にファイルを翻訳リクエストを投げるファイルと翻訳リクエストの送信を依頼するファイルに分割しました。

以下は翻訳リクエストの送信を依頼するファイルです。

// extension.ts
import * as vscode from 'vscode'

import { Translator } from './translator'

export async function activate(context: vscode.ExtensionContext) {
  const text: string = 'test'
  const res: string = await new Translator(text).exec()
  const message: string = formatResult(text, res)

  const disposable = vscode.commands.registerCommand('variable-translator.translateVariable', () => {
    vscode.window.showInformationMessage(message)
  })

  context.subscriptions.push(disposable)
}

function formatResult(text: string, res: string): string {
  return `from: ${text} -> to: ${res}`
}

export function deactivate() { }

以下は翻訳 API に翻訳リクエストを送信するファイルです。

// translator.ts
import * as vscode from 'vscode'

import { response } from './types'

export class Translator {
  #inputText: string
  #response: response | null = null

  constructor(text: string) {
    if (text.length === 0) throw new Error('文字列が指定されていません。')
    if (/(^[^a-zA-Z]+$|[^a-zA-Z0-9!?])/.test(text)) throw new Error('不正な文字列です。')

    this.#inputText = text
  }

  private set response(res) { this.#response = res }

  get inputText(): string { return this.#inputText }
  get response(): response | null { return this.#response }
  get outputText(): string | null { return this.response?.text || null }

  async exec(): Promise<string> {
    this.response = await this.request(this.inputText)

    if (!this.response || !this.outputText) throw new Error('結果が空です。')

    return this.outputText
  }

  async request(text: string): Promise<response> {
    const params = { text }
    const query = new URLSearchParams(params)
    const endpoint = vscode.workspace.getConfiguration('variableTranslator').get<string>('apiEndPoint')

    const url = `${endpoint}?${query}`
    return await fetch(url).then(data => data.json()) as response
  }
}

2. バリデーションを別クラスに移行する

次に、Translator クラス内にあったバリデーション機能を Validator クラスとして切り出しました。

// validator.ts
export class Validator {
  #inputText: string

  private set inputText(text: string) { this.#inputText = text }

  get inputText() { return this.#inputText }

  constructor(text: string) {
    if (text.length === 0) throw new Error('文字列が指定されていません。')

    this.#inputText = text
  }

  exec() {
    if (this.inputText.length === 0) throw new Error('文字列が指定されていません。')
    if (/(^[^a-zA-Z]+$|[^a-zA-Z0-9!?_-])/.test(this.inputText)) throw new Error('不正な文字列です。')

    return true
  }
}

次に、新規で Runner クラスを作成し、extensions.ts で直接呼び出していた翻訳関連の処理を、 Runner クラス内で実行する様に変更しました。

// runner.ts
import { Validator } from './validator'
import { Translator } from './translator'

export class Runner {
  #inputText: string
  #responseText: string = ''

  private set responseText(text: string) { this.#responseText = text }

  get inputText() { return this.#inputText }
  get responseText() { return this.#responseText }
  get outputMessage() {
    if (!this.responseText) throw new Error('結果が空です。')
    return `from: ${this.inputText} -> to: ${this.responseText}`
  }

  constructor(text: string) {
    if (text.length === 0) throw new Error('文字列が指定されていません。')

    this.#inputText = text
  }

  async exec(): Promise<string> {
    new Validator(this.inputText).exec()

    this.responseText = await new Translator(this.inputText).exec()

    return this.outputMessage
  }
}

Runner クラスの実装に伴い、extension.ts では Runner クラスのみ呼び出すように変更しました。

3. パーサーを実装する

通常のプログラミングに使う変数名・メソッド名の形式のままでは翻訳しにくいため、空白で分書できるようにパーサーを実装しました。

以下が文字列のパースを行う Parse クラスです。

// parser.ts
import { Case } from './cases/case'
import { KebabCase } from './cases/kebab_case'
import { SnakeCase } from './cases/snake_case'
import { CamelCase } from './cases/camel_case'

export class Parser {
  #inputText: string
  #outputText: string = ''

  private set outputText(text: string) { this.#outputText = text }

  get inputText() { return this.#inputText }
  get outputText() { return this.#outputText }

  constructor(text: string) {
    if (text.length === 0) throw new Error('文字列が指定されていません。')

    this.#inputText = text
  }

  exec(): string {
    const caseClass = this.findClass(this.inputText)
    return new caseClass(this.inputText).naturalText
  }

  private findClass(text: string) {
    const caseClasses = [SnakeCase, KebabCase, CamelCase]
    const caseClass = caseClasses.find((caseClass) => caseClass.applyTo(text))

    return caseClass || Case
  }
}

Parse クラスでは文字列に含まれる文字から対応するクラスを決定し、同じ名称で用意している、パース結果を返却する getter メソッドを呼び出すようにしています。 文字列に対応するクラスは以下のように実装しています。

// case.ts
export class Case {
  #inputText: string

  get inputText(): string { return this.#inputText }
  get naturalText(): string { return this.trimUnderscore(this.inputText) }

  constructor(text: string) {
    if (text.length === 0) throw new Error('文字列が指定されていません。')

    this.#inputText = text
  }

  static applyTo(_: string): boolean {
    return callUnableMethod()
  }

  private trimUnderscore(text: string): string {
    return Case.trimUnderscore(text)
  }

  static trimUnderscore(text: string): string {
    if (text[0] === '_') return Case.trimUnderscore(text.slice(1))
    if (text.slice(-1) === '_') return Case.trimUnderscore(text.slice(0, -1))
    return text
  }
}

function callUnableMethod(): any {
  throw new Error('許可されていない呼び出しです。')
}
// snake_case.ts
import { Case } from './case'

export class SnakeCase extends Case {
  get naturalText(): string { return super.naturalText.split('_').join(' ').toLowerCase() }

  constructor(text: string) {
    super(text)
  }

  static applyTo(text: string): boolean {
    return super.trimUnderscore(text).includes('_')
  }
}

4. 右クリックメニューから機能の呼び出す

次に、右クリックで対象のコマンドを呼び出せる(翻訳対象の文字列はハードコードのままで良い)を目指しました。

以下を参考に、 package.json 内の contributesmenus の項目を追加して、右クリックのメニューに今回私が作成したコマンドを表示して、選択すると実行できるようにしました。

code.visualstudio.com

// package.json
{
  // 略
  "contributes": {
    "commands": [
      {
        "command": "variable-translator.translateVariable",
        "title": "Translate Variable"
      }
    ],
    "configuration": {
      "title": "variableTranslator",
      "properties": {
        "variableTranslator.apiEndPoint": {
          "type": "string",
          "description": "翻訳 API のエンドポイント"
        }
      }
    },
    "menus": {
      "editor/context": [
        {
          "when": "editorHasSelection",
          "command": "variable-translator.translateVariable",
          "group": "z_commands"
        }
      ]
    }
  },
  // 略
}

適当な文字列を選択してから右クリックすると、コンテキストメニュー内に今回作成した機能用のメニューが表示されました。

該当のメニューを選択すると機能が呼び出されて、コマンドパレットからの呼び出し時と同様に翻訳結果が表示されることを確認できました。

次に、選択した文字列を翻訳機能に渡す処理を実装しました。

選択状態のテキストを取得する API が用意されているため、API を利用して選択中の文字列を取り出して、翻訳機能に渡すように修正しました。

code.visualstudio.com

// extension.ts
import * as vscode from 'vscode'

import { Runner } from './runner'

export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand('variable-translator.translateVariable', async () => {
    const activeEditor = vscode.window.activeTextEditor
    const doc = activeEditor && activeEditor.document
    const ref = activeEditor?.selection

    const text: string | undefined = doc?.getText(ref)
    const message = await new Runner(text).exec()

    vscode.window.showInformationMessage(message)
  })

  context.subscriptions.push(disposable)
}

export function deactivate() { }

任意のアルファベット文字列を選択してから、右クリックで翻訳機能を選択すると、選択した文字列が翻訳されて表示されることを確認できました。

5. エラーを画面表示する

現時点では予測しない文字列が入力された場合に何も起こらないため、extension.ts でエラーをキャッチしたら、エラーメッセージを表示するようにしました。

// extension.ts
import * as vscode from 'vscode'

import { Runner } from './runner'

export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand('variable-translator.translateVariable', async () => {
    try {
      const activeEditor = vscode.window.activeTextEditor
      const doc = activeEditor && activeEditor.document
      const ref = activeEditor?.selection

      const text: string | undefined = doc?.getText(ref)
      const message = await new Runner(text).exec()

      vscode.window.showInformationMessage(message)
    } catch (err: any) {
      vscode.window.showErrorMessage(err.message)
    }
  })

  context.subscriptions.push(disposable)
}

export function deactivate() { }

以上で、想定しない文字列(e.g. 日本語文字列)が選択されていた場合に、エラーメッセージが画面へ表示されるようになりました。

6. 拡張機能をパッケージ化する

今回はあくまでも(主に翻訳機能との兼ね合いで)自分が使用することしか考えていない拡張機能のため、公開せず利用できるようにするパッケージ化を行いました。

code.visualstudio.com

まずは以下のコマンドでパッケージ化用のコマンドを利用可能にしました。

$ npm install -g @vscode/vsce

次にコンパイルをしてからパッケージ化のコマンドを実行しましたが、エラーになりました。

$ npm run vscode:prepublish
$ vsce package

Executing prepublish script 'npm run vscode:prepublish'...

> [email protected] vscode:prepublish
> npm run compile

> [email protected] compile
> tsc -p ./

 ERROR  It seems the README.md still contains template text. Make sure to edit the README.md file before you package or publish your extension.

README.md がテンプレートの状態のままだとエラーになるようなので、README.md を編集しました。

# variable-translator README

変数やメソッド名を日本語に翻訳する VSCode の拡張機能です。

再度同じコマンドを実行すると、いくつか質問を挟んで問題なくパッケージ化が終了していることを確認できました。

$ vsce package

Executing prepublish script 'npm run vscode:prepublish'...

> [email protected] vscode:prepublish
> npm run compile

> [email protected] compile
> tsc -p ./

 WARNING  A 'repository' field is missing from the 'package.json' manifest file.
Use --allow-missing-repository to bypass.
Do you want to continue? [y/N] y
 WARNING  LICENSE, LICENSE.md, or LICENSE.txt not found
Do you want to continue? [y/N] y
 WARNING  This extension consists of 3304 files, out of which 2103 are JavaScript files. For performance reasons, you should bundle your extension: https://aka.ms/vscode-bundle-extension. You should also exclude unnecessary files by adding them to your .vscodeignore: https://aka.ms/vscode-vscodeignore.

 INFO  Files included in the VSIX:
variable-translator-0.0.1.vsix
├─ [Content_Types].xml 
├─ extension.vsixmanifest 
└─ extension/
   ├─ changelog.md [0.24 KB]
   ├─ package.json [1.25 KB]
   ├─ readme.md [0.11 KB]
   ├─ node_modules/ (3281 files) [47.35 MB]
   └─ out/ (18 files) [50 KB]

=> Run vsce ls --tree to see all included files.

 DONE  Packaged: /Users/***/variable-translator/variable-translator-0.0.1.vsix (3304 files, 17.71 MB)

7. 拡張機能をインストールして利用可能にする

ローカルで拡張機能を利用できるようにするため、拡張機能のインストールを(今回はコマンドで)行いました。

code.visualstudio.com

以下のコマンドを実行したところ、インストール成功した旨が表示されました。 その後、拡張機能一覧に自分で作成した拡張機能が表示されるようになりました。

$ code --install-extension variable-translator-0.0.1.vsix
Installing extensions...
Extension 'variable-translator-0.0.1.vsix' was successfully installed.

適当なファイルにテスト用の文字列を入力して、右クリックを押すと専用メニューが表示されました。

表示されたメニューを選択すると翻訳結果が期待通り表示されました。

想定しない文字列を入力した場合もエラーが発生していることを示すメッセージが画面に表示されました。

まとめ

VSCode 内で手軽に翻訳をするために、VSCode の拡張機能を自作しました。 途中でコンパイルを忘れて、想定しない実行結果に悩まされていたりもしましたが、最終的には無事動くものを作れ、良い経験になりました。

VSCode + textlint で文章校正を行う

概要

個人的なメモで文章を書く際に文章の表記揺れが発生していることがあり、自分で後から見直す際に読みにくくなっていたため、VSCode で textlint を導入しました。

環境

  • Node.js:v20.18.0
  • Volta:v2.0.1

やったこと

1. textlint をインストールする

今回は全ワークスペース下で共通の設定を作りたいため、textlint とルールセットのひとつ textlint-rule-no-doubled-joshi をグローバルインストールしました。

$ npm install -g textlint textlint-rule-no-doubled-joshi

github.com github.com

ホームディレクトリで textlint --init を実行し、作成された .textlint.json に以下のようにルールの有効化をしました。

{
  "plugins": {},
  "filters": {},
  "rules": {
    "no-doubled-joshi": true
  }
}

textlint コマンドを実行したところ、パッケージに沿った修正候補が出ていることを確認できました。

2. VSCode の拡張機能をインストールする

VSCode の拡張機能 vscode-textlint をインストールしました。

marketplace.visualstudio.com

拡張機能をインストールして、以下のように適当なファイルで引っかかる表現を入れるとエラーとして表示されることを確認できました。 ※同一行にエラーの内容が表示されているのは別の拡張機能によるものです。

ルールセットを追加する

基本的な挙動は問題ないことを確認できたため、以下を参考にいくつかルールセット用のパッケージを追加しました。

github.com

  • textlint-rule-preset-ja-technical-writing (技術文書向けルールセット)
  • textlint-rule-incremental-headers (Markdown のヘッダー用ルールセット)
  • textlint-rule-ja-no-orthographic-variants (表記揺れチェック用ルールセット)

まとめ

VSCode に textlint を導入することで、統一的な文章を書きやすくなったかと思います。 今回は最小限の設定とルールのみ追加しましたが、必要に応じてルール・パッケージを拡張することで、メモの品質をある程度担保できるかと思います。

(2..0).size・(2..0).each を読み解く

はじめに

Ruby で (2..0) のように値の大きい方を先に、小さい方を後に定義した場合に、以下のようにエラーにはならない、かつ範囲内の要素数が 0 になる挙動が不思議に思いました。

(0..2).each { |v| p v }
# ↓出力結果
# 0
# 1
# 2
(2..0).each { |v| p v }
# 出力結果なし
(2..1).size
=> 0

そのため、Range の定義、および関連する処理で何を行っているのか、Ruby のソースコードを眺めながら調べてみました。

調査

1. Ruby のリファレンスを見る

まずは、Ruby のリファレンスの Range クラスのページを全体的に眺めました。

リファレンスを確認していると、overlap? メソッドの説明で以下のような記述を見つけました。

ここで、Range が空であるとは、

  • 始端が終端より大きい
  • Range#exclude_end? が true であり、始端と終端が等しい

のいずれかを満たすことをいいます。

https://docs.ruby-lang.org/ja/latest/method/Range/i/overlap=3f.html

つまり、 (2..0) のような定義をした場合、Range は空であるものとして取り扱われるようです。

また、(2..0)Range.new(2, 0) と同じ意味になるので、実際のソースコードを読む際には後者の定義に紐づくコードを探すことにしました。

Ruby のコードの読み方を把握する

Ruby のソースコードの構成把握のために、以下に目を通しました(2004年に公開されたもののようですので、あくまで参考程度に)。

https://i.loveruby.net/ja/rhg/book/(とくに第4章クラスとモジュール、第5章ガ-ベージコレクション > オブジェクトの生成)

上によると、Ruby のコードでのクラス・メソッドの定義は以下のようなルールになっているようです。

  • クラスの定義を格納している変数は rb_c#{クラス名} というルールで統一されている
  • クラスに含まれるメソッドは Init_#{クラス名} メソッド内で rb_define_method(rb_c#{クラス名}, "#{メソッド名}", #{メソッドの実体}, #{引数の数}) の形式で呼び出している
  • new メソッドは rb_class_new_instance で定義され、その中で initialize が呼び出されている

そのため、以下のように調査方法の方針を立てました。

  1. rb_class_new_instance を確認し、現在も上記資料の通りに読んで問題なさそうか確認しておく)
  2. Init_Range メソッドを探す
  3. rb_define_singleton_method(rb_cRange, "new" を探し、Range.new メソッドを探す
  4. Range.new のコードを読む(ここで十分に調査できれば打ち切る)
  5. 調査が不十分な場合、sizeeach メソッドも合わせて調査する

実際にソースコードを読む

前項で確認した読み方をもとに、GitHub のリポジトリにある Ruby のコードを順に追っていきました。

確認したソースコードは v3.3.5 のものでした。

1. rb_class_new_instance を確認する

rb_class_new_instance を探したところ、Object クラスにそれらしい関数を見つけました。

/*
 *  call-seq:
 *     class.new(args, ...)    ->  obj
 *
 *  Calls #allocate to create a new object of <i>class</i>'s class,
 *  then invokes that object's #initialize method, passing it
 *  <i>args</i>.  This is the method that ends up getting called
 *  whenever an object is constructed using <code>.new</code>.
 *
 */

VALUE
rb_class_new_instance_pass_kw(int argc, const VALUE *argv, VALUE klass)
{
    VALUE obj;

    obj = rb_class_alloc(klass);
    rb_obj_call_init_kw(obj, argc, argv, RB_PASS_CALLED_KEYWORDS);

    return obj;
}

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/object.c#L2123-L2161

rb_class_new_instance_pass_kw の記述が前述の資料と一致していることと、メソッド名に new が指定されていること、コメントの内容から、new メソッドでrb_class_new_instance_pass_kw が呼び出されており、rb_class_new_instance_pass_kw の処理中で対象クラス(今回の場合は Range)の initialize メソッドが呼び出されているようです。

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/object.c#L4487

2. Range クラスの initialize メソッドを確認する

まず、 initialize メソッドが定義されていると思われる Init_Range を探し、紐づいているメソッドの実体を探しました。

記事の通り、 Init_Range 内に rb_define_method を使って initialize メソッドとその実体 range_initialize が紐づけられており、 range_initialize を探していきました。

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L2641

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L85-L109

/*
 *  call-seq:
 *    Range.new(begin, end, exclude_end = false) -> new_range
 *
 *  Returns a new range based on the given objects +begin+ and +end+.
 *  Optional argument +exclude_end+ determines whether object +end+
 *  is included as the last object in the range:
 *
 *    Range.new(2, 5).to_a            # => [2, 3, 4, 5]
 *    Range.new(2, 5, true).to_a      # => [2, 3, 4]
 *    Range.new('a', 'd').to_a        # => ["a", "b", "c", "d"]
 *    Range.new('a', 'd', true).to_a  # => ["a", "b", "c"]
 *
 */

static VALUE
range_initialize(int argc, VALUE *argv, VALUE range)
{
    VALUE beg, end, flags;

    rb_scan_args(argc, argv, "21", &beg, &end, &flags);
    range_modify(range);
    range_init(range, beg, end, RBOOL(RTEST(flags)));
    return Qnil;
}

関数名と引数を見ると、おそらく rb_scan_args は引数として渡ってきた begunendexclude_end に該当する値を begendflags に格納していると推測しました。また、処理全体からの推測ですが、 Range のインスタンス本体は引数 range に格納されていそうです。

range_modifyfreeze しているかの確認とエラー処理のみのようなので、読み飛ばしました。

range_init を確認しました。最初の if 文で開始値・終了値の判定をしているが、中身の処理を確認する限りエラー処理のみに関係しているようなので、エラーが出ていない今回は読み飛ばしました。その後、構造体に各引数をセットして最後に freeze しているよう(※)なので、 Range.new で実際に範囲を指定して定義する処理は、range_init の内容がほとんどと推測しました。

(※)破壊的な変更の項目に一度生成した Rangefreeze することが記載されています。

https://docs.ruby-lang.org/ja/latest/class/Range.html

static void
range_init(VALUE range, VALUE beg, VALUE end, VALUE exclude_end)
{
    if ((!FIXNUM_P(beg) || !FIXNUM_P(end)) && !NIL_P(beg) && !NIL_P(end)) {
        VALUE v;

        v = rb_funcall(beg, id_cmp, 1, end);
        if (NIL_P(v))
            rb_raise(rb_eArgError, "bad value for range");
    }

    RANGE_SET_EXCL(range, exclude_end);
    RANGE_SET_BEG(range, beg);
    RANGE_SET_END(range, end);

    if (CLASS_OF(range) == rb_cRange) {
        rb_obj_freeze(range);
    }
}

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L46-L64

range_initialize に戻ると、range_init のあとに return Qnil; で値を返却しているようですが、 range の構造を変更するものではないと推測して深追いはしませんでした。

ここまで中身を見る限り、この時点ではとくに Range の定義で開始値と終了値の大小に関わる制限は見られなかったため、引き続き処理を確認することにしました。

3. Range クラスの size メソッドを確認する

まず、 Init_Range から size メソッドに紐づく定義を確認しました。size メソッドは range_size に紐づいているようです。

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L2659

/*
 *  call-seq:
 *    size -> non_negative_integer or Infinity or nil
 *
 *  Returns the count of elements in +self+
 *  if both begin and end values are numeric;
 *  otherwise, returns +nil+:
 *
 *    (1..4).size      # => 4
 *    (1...4).size     # => 3
 *    (1..).size       # => Infinity
 *    ('a'..'z').size  #=> nil
 *
 *  Related: Range#count.
 */

static VALUE
range_size(VALUE range)
{
    VALUE b = RANGE_BEG(range), e = RANGE_END(range);
    if (rb_obj_is_kind_of(b, rb_cNumeric)) {
        if (rb_obj_is_kind_of(e, rb_cNumeric)) {
            return ruby_num_interval_step_size(b, e, INT2FIX(1), EXCL(range));
        }
        if (NIL_P(e)) {
            return DBL2NUM(HUGE_VAL);
        }
    }
    else if (NIL_P(b)) {
        if (rb_obj_is_kind_of(e, rb_cNumeric)) {
            return DBL2NUM(HUGE_VAL);
        }
    }

    return Qnil;
}

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L819-L854

コードの外観としては range_init で格納した begend をそれぞれ be に格納して、その型によって処理・返り値を振り分けている、といった感じのようです。

今回は (2..0) のような整数値で定義された範囲を想定するため、be の両方が数値の判定になり、ruby_num_interval_step_size(b, e, INT2FIX(1), EXCL(range)) が実行されると考えられます。この関数は numeric.c(include された numeric.h 経由で呼び出されたものと推測)に定義されているようなので、該当の関数の処理を確認します。

VALUE
ruby_num_interval_step_size(VALUE from, VALUE to, VALUE step, int excl)
{
    if (FIXNUM_P(from) && FIXNUM_P(to) && FIXNUM_P(step)) {
        long delta, diff;

        diff = FIX2LONG(step);
        if (diff == 0) {
            return DBL2NUM(HUGE_VAL);
        }
        delta = FIX2LONG(to) - FIX2LONG(from);
        if (diff < 0) {
            diff = -diff;
            delta = -delta;
        }
        if (excl) {
            delta--;
        }
        if (delta < 0) {
            return INT2FIX(0);
        }
        return ULONG2NUM(delta / diff + 1UL);
    }
    // 以下略
}

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/numeric.c#L2797-L2841

最初の if 文では開始値、終了値、ステップがすべて Fixnum であるかを確認しています。

Fixnum は v3.2.0 で廃止されているようですが、 Integer クラスに統合されているようなので、Ruby 側から見ると Integer であるかを確認しているものと推測します。

https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/

<https://bugs.ruby-lang.org/issues/12005>

そのため、おそらくここでの処理は true になると推測して中の処理を引き続き確認しました。

次に step を格納している diffの値での条件分岐がされていますが、今回は 1 が指定されているため処理はスキップしました。

excl の判定も今回は末尾の値を含むため、 0 が渡されていると推測し、スキップしました。

最後に開始値と終了値の差分を見ている delta の判定ですが、(2..0) の場合、 from2to0 です。そのため、 delta = FIX2LONG(to) - FIX2LONG(from) < 0 となり、最後の判定で 0 が返却されることになります。

以上より、(2..0).size0 を返却する処理は、この関数の処理によるもののようです。

4. each メソッドも確認してみる

size と同様に each メソッドも確認してみました。

Init_Range 内を確認すると、 range_each 関数で処理を定義しているようです。

static VALUE
range_each(VALUE range)
{
    VALUE beg, end;
    long i;

    RETURN_SIZED_ENUMERATOR(range, 0, 0, range_enum_size);

    beg = RANGE_BEG(range);
    end = RANGE_END(range);

    if (FIXNUM_P(beg) && NIL_P(end)) {
        range_each_fixnum_endless(beg);
    }
    else if (FIXNUM_P(beg) && FIXNUM_P(end)) { /* fixnums are special */
        return range_each_fixnum_loop(beg, end, range);
    }
    // 以下略
}

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L916-L1025

コードの外観としてはsizeと同じように range_init で格納した begend をそれぞれ取得して、その型によって処理・返り値を振り分けている、といった感じのようです。

今回は 2つ目の分岐 FIXNUM_P(beg) && FIXNUM_P(end) ではじめて true になると想定されるため、 range_each_fixnum_loop の処理を確認します。

static VALUE
range_each_fixnum_loop(VALUE beg, VALUE end, VALUE range)
{
    long lim = FIX2LONG(end) + !EXCL(range);
    for (long i = FIX2LONG(beg); i < lim; i++) {
        rb_yield(LONG2FIX(i));
    }
    return range;
}

https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L906-L914

中身はシンプルでループの終了値を Range の終了値と末尾を含むかから計算して、開始値からループの終了値までの値を、ループごとに加算しながら順に返却していく、といった処理のようです。そのため開始値がすでに終了値より大きいため、ループ処理に入らず処理が終了している、というのが、 (2..0).each{ |v| p v } で値が出てこない直接的な原因のようです。

まとめ

(2..0) のように、大きい値を先に、小さい値を後に入れて Range を定義した場合に、 size0 になったり、 each で値が返ってこない事象に対し、Ruby のコードを見ながら何が行われているか確認をしました。

一部のメソッドの挙動しか見ませんでしたが、なんとなく上記のケースを意図的に想定しないように作っていそう(実際reverse_each メソッドなども用意されているあたりも合わせて)推測したりできた気がしました。

2024/10/9 追記

TechRacho さんで以下のような翻訳記事が公開されていました。

techracho.bpsinc.jp

記事の中で、今回取り上げた (2..0) のような逆順の Range の定義が許容されていることについて、array[2..-1] のような Range を用いたスライスをできるようにするためではないかとの話が出ていました。

techracho.bpsinc.jp docs.ruby-lang.org

Array のスライスでの Range の利用における挙動も調べてみるとおもしろいかもしれません。

Gist HTML Preview で Gist に投稿した HTML+CSS+JavaScript のコードをプレビューする

はじめに

Gist HTML Preview というサービスを使って、localhost を参照せず、Gist にアップロードした HTML+CSS+JavaScript のコードをブラウザでプレビューしました。

サービス:

gistpreview.github.io

GitHub: github.com

選定の経緯

たまにコードのサンプルはあるものの、デモを見つけられないコードがあります。 今回は上記のようなコードの動作確認のため、以下の観点でサービス・手法を選定しました。

  • JavaScript の挙動をさまざまな端末で手軽に検証できるようにしたい
  • 手軽に確認したいため、デプロイ作業は不要な方法が良い
  • 現状登録していないサービスへの登録は避けたい
    • JSFiddle、CodePen などのプレイグラウンドサービスは未登録のため、今回は使いたくない
  • あくまで言語的な仕様確認を目的としたサンプルコードの確認のため、ソースコードを完全に秘匿しなくてよい

以上の観点を踏まえて、今回は Gist HTML Preview を採用しました。

  • URL を共有すれば検証可能
  • Gist のコードを変更するだけで、プレビューにも変更を反映できる
  • GitHub アカウント下でソースコードを管理可能

gistpreview.github.io

やってみた結果

以下のように、作成したコードの動作確認をできました。

やったこと

1. コードを作成する

今回は以下のようなコードを作成しました。

<html lang="ja">
  <head>
    <title>getBattery demo</title>
  </head>
  <body>
    <p>充電:</p>
    <p id="batteryStatus" />
  </body>

  <script>
    const batteryStatusDom = document.getElementById("batteryStatus")

    navigator.getBattery().then((battery) => {
      batteryStatusDom.textContent = `${battery.level * 100}%`

      battery.addEventListener("levelchange", () => {
        batteryStatusDom.textContent = `${battery.level * 100}%`
      })
    })
  </script>
</html>

参考: developer.mozilla.org

2. Gist に作成したコードを登録する

GitHub Gist にコードを登録します。

gist.github.com

ファイル名を指定しない場合は拡張子が .txt のファイルになります。しかし、中身が HTML であればプレビュー自体はできるようです。 今回はファイル名を sample.html とし、1. で提示したコードを添付して、「Create secret gist」を押して作成しました。

3. ハッシュ値をコピーする

Gist の URL から Gist のコードに割り当てられたランダムな値をコピーします。

URL は Gist のコードの画面の URL、もしくはコード右上の Embed を Share に変更して得られる URL で良いです。

たとえば、以下のような URL の場合、 {randomvalue} の位置に当たる英数字が今回コピーする値になります(実際には意味のないランダムな英数字が現れます)。

https://gist.github.com/username/{randomvalue}

4. Gist HTML Preview で2のファイルをプレビューする

まず、Gist HTML Preview にアクセスします。

gistpreview.github.io

Gist ID の欄に3でコピーした値をペーストして、Submit ボタンを押します。 コピーした値が {randomvalue} の場合は以下のように入力します。

Submit を押すと、登録した HTML コードがレンダリングされた状態で表示され、コードが動作していることを確認できました。

まとめ

Gist HTML Preview を使って Gist 上の HTML+CSS+JavaScript のコードをブラウザで表示しました。

今回は JavaScript の動作確認に利用しましたが、HTML、CSS の動作確認にも同様に使えるかと思います。

また、挙動をすぐに確認できるサンプルはないけど、サンプルコードの挙動を確かめたい。でも、無闇にサービス登録したくはない、みたいなケースで使えそうです。

RSpec + Shrine で updated_at を使ったテストがこけて対応した話

はじめに

RSpec + Shrine の環境のテストで updated_at がうまく設定できない事象を調査・解消したので備忘としてまとめておきます。

起こった問題

問題がなかった状態

以下のようなモデル・コントローラーを作りました。

  • モデル:
class Article < ApplicationRecord
end
  • コントローラー
class ArticlesController < ApplicationController
  def index
    @articles = Article.all.order(:updated_at)
  end
end

このモデル・コントローラーに対して、以下のような RSpec のテストと、FactoryBot の設定をしました。

require 'rails_helper'

RSpec.describe 'Articles', type: :request do
  describe 'GET /index' do
    let!(:newer_article) { create :article, updated_at: Date.new(2024, 6, 20) }
    let!(:older_article) { create :article, updated_at: Date.new(2024, 6, 19) }

    it do
      get '/articles/index.json'
      response_json = response.parsed_body

      p '=== newer_article ==='
      pp newer_article
      p '=== older_article ==='
      pp older_article
      p '=== response ==='
      pp response_json

      expect(response_json[0]['id']).to eq older_article.id
      expect(response_json[1]['id']).to eq newer_article.id
    end
  end
end
FactoryBot.define do
  factory :article do
    title { 'test title' }
    body { 'sample body' }
  end
end

この時点ではテストは問題なくパスしていました。

$ rspec
"=== newer_article ==="
#<Article:0x00007f49dae28cb0
 id: 1,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 01:13:49.600516000 UTC +00:00,
 updated_at: Thu, 20 Jun 2024 00:00:00.000000000 UTC +00:00,
 image_data: nil>
"=== older_article ==="
#<Article:0x00007f49da92dd50
 id: 2,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 01:13:49.606225000 UTC +00:00,
 updated_at: Wed, 19 Jun 2024 00:00:00.000000000 UTC +00:00,
 image_data: nil>
"=== response ==="
[{"id"=>2, "title"=>"test title", "body"=>"sample body"},
 {"id"=>1, "title"=>"test title", "body"=>"sample body"}]
.

Finished in 0.34433 seconds (files took 5.86 seconds to load)
1 example, 0 failures

問題が起こった状態

この状態から、Shrine の gem を使って画像をアップロードできるように変更しました。

shrinerb.com

  • ImageUploader
class ImageUploader < Shrine
  # plugins and uploading logic
end
  • モデル
class Article < ApplicationRecord
  include Shrine::Attachment(:image)
end
  • FactoryBot
FactoryBot.define do
  factory :article do
    title { 'test title' }
    body { 'sample body' }
    image { File.new('spec/fixtures/test.png') }
  end
end

この状態でテストを実行したところ、updated_at の値が変更されて、返却順が変わったことでテストがこけるようになっていました。

$ rspec
"=== newer_article ==="
#<Article:0x00007f6332e98760
 id: 1,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 02:50:04.749216000 UTC +00:00,
 updated_at: Sun, 23 Jun 2024 02:50:04.762817000 UTC +00:00,
 image_data:
  "{\"id\":\"5132a30ebd927dbe516b933e9a967fe2.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}">
"=== older_article ==="
#<Article:0x00007f633299c950
 id: 2,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 02:50:04.795344000 UTC +00:00,
 updated_at: Sun, 23 Jun 2024 02:50:04.805194000 UTC +00:00,
 image_data:
  "{\"id\":\"325af135c2acfb43169c48c522c1d2c5.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}">
"=== response ==="
[{"id"=>1, "title"=>"test title", "body"=>"sample body"},
 {"id"=>2, "title"=>"test title", "body"=>"sample body"}]
F

Failures:

  1) Articles GET /index is expected to eq 2
     Failure/Error: expect(response_json[0]['id']).to eq older_article.id
     
       expected: 2
            got: 1
     
       (compared using ==)
     # ./spec/requests/articles_spec.rb:19:in `block (3 levels) in <top (required)>'

Finished in 0.45642 seconds (files took 6.12 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/requests/articles_spec.rb:8 # Articles GET /index is expected to eq 2

調査

状況整理

shrine を使って画像をアップロードするようにしてからテストが動かなくなったというのが今回の状況です。

また、updated_at が更新されていることから、インスタンスの作成後に何らかの SQL 動いていそうです。

原因の切り分け

一度 shrine の各種設定はそのまま、かつ以下のように画像のアップロードがない状態で RSpec のテストを再度走らせてみました。

FactoryBot.define do
  factory :article do
    title { 'test title' }
    body { 'sample body' }
    image {}
  end
end

実行結果を確認したところ、updated_at が指定通りの日時になっており、問題なくテストをパスすることを確認できました。

$ rspec
"=== newer_article ==="
#<Article:0x00007f9527a9a100
 id: 1,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 03:17:15.470633000 UTC +00:00,
 updated_at: Thu, 20 Jun 2024 00:00:00.000000000 UTC +00:00,
 image_data: nil>
"=== older_article ==="
#<Article:0x00007f952759dc10
 id: 2,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 03:17:15.477019000 UTC +00:00,
 updated_at: Wed, 19 Jun 2024 00:00:00.000000000 UTC +00:00,
 image_data: nil>
"=== response ==="
[{"id"=>2, "title"=>"test title", "body"=>"sample body"},
 {"id"=>1, "title"=>"test title", "body"=>"sample body"}]
.

Finished in 0.36878 seconds (files took 5.66 seconds to load)
1 example, 0 failures

テスト実行時のログの調査

原因はおおよそわかったので、次に画像アップロード時に何が起こっているか確認するためにログを確認しました。

まず、画像をアップロードをしない場合のテスト実行時のログを確認しました。

$ cat log/test.log 
  ActiveRecord::InternalMetadata Load (0.6ms)  SELECT * FROM "ar_internal_metadata" WHERE "ar_internal_metadata"."key" = ? ORDER BY "ar_internal_metadata"."key" ASC LIMIT 1  [[nil, "schema_sha1"]]
  ActiveRecord::SchemaMigration Load (0.5ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
  TRANSACTION (0.1ms)  begin transaction
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  Article Create (1.6ms)  INSERT INTO "articles" ("title", "body", "created_at", "updated_at", "image_data") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["title", "test title"], ["body", "sample body"], ["created_at", "2024-06-23 03:54:48.899419"], ["updated_at", "2024-06-20 00:00:00"], ["image_data", nil]]
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
  Article Create (2.5ms)  INSERT INTO "articles" ("title", "body", "created_at", "updated_at", "image_data") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["title", "test title"], ["body", "sample body"], ["created_at", "2024-06-23 03:54:48.907741"], ["updated_at", "2024-06-19 00:00:00"], ["image_data", nil]]
  TRANSACTION (0.5ms)  RELEASE SAVEPOINT active_record_1
Started GET "/articles/index.json" for 127.0.0.1 at 2024-06-23 03:54:48 +0000
Processing by ArticlesController#index as JSON
  Rendering articles/index.json.jbuilder
  Article Load (0.2ms)  SELECT "articles".* FROM "articles" ORDER BY "articles"."updated_at" ASC
  Rendered articles/index.json.jbuilder (Duration: 5.0ms | Allocations: 558)
Completed 200 OK in 78ms (Views: 23.2ms | ActiveRecord: 0.2ms | Allocations: 5829)
  TRANSACTION (0.1ms)  rollback transaction

次に、まず、画像をアップロードをする場合のテスト実行時のログを確認しました。

INSERT 分でのレコード作成後に、 UPDATE 文で更新タイミングの時刻で updated_at 更新がかかっていることが分かりました。

$ cat log/test.log 
  ActiveRecord::InternalMetadata Load (0.6ms)  SELECT * FROM "ar_internal_metadata" WHERE "ar_internal_metadata"."key" = ? ORDER BY "ar_internal_metadata"."key" ASC LIMIT 1  [[nil, "schema_sha1"]]
  ActiveRecord::SchemaMigration Load (0.4ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
  TRANSACTION (0.1ms)  begin transaction
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  Article Create (1.3ms)  INSERT INTO "articles" ("title", "body", "created_at", "updated_at", "image_data") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["title", "test title"], ["body", "sample body"], ["created_at", "2024-06-23 03:26:35.095011"], ["updated_at", "2024-06-20 00:00:00"], ["image_data", "{\"id\":\"042a5d1290474df6d6ce5991e093e930.png\",\"storage\":\"cache\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}"]]
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  Article Update (1.1ms)  UPDATE "articles" SET "updated_at" = ?, "image_data" = ? WHERE "articles"."id" = ?  [["updated_at", "2024-06-23 03:26:35.109074"], ["image_data", "{\"id\":\"01478f78b5c25ba21a285abc458163a5.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}"], ["id", 1]]
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  Article Create (1.3ms)  INSERT INTO "articles" ("title", "body", "created_at", "updated_at", "image_data") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["title", "test title"], ["body", "sample body"], ["created_at", "2024-06-23 03:26:35.141299"], ["updated_at", "2024-06-19 00:00:00"], ["image_data", "{\"id\":\"630b9a4a01f703f2534c8b0587cf24a2.png\",\"storage\":\"cache\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}"]]
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  Article Update (1.3ms)  UPDATE "articles" SET "updated_at" = ?, "image_data" = ? WHERE "articles"."id" = ?  [["updated_at", "2024-06-23 03:26:35.154750"], ["image_data", "{\"id\":\"c5b54dec0f1b455088e87f50985692ec.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}"], ["id", 2]]
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
Started GET "/articles/index.json" for 127.0.0.1 at 2024-06-23 03:26:35 +0000
Processing by ArticlesController#index as JSON
  Rendering articles/index.json.jbuilder
  Article Load (0.4ms)  SELECT "articles".* FROM "articles" ORDER BY "articles"."updated_at" ASC
  Rendered articles/index.json.jbuilder (Duration: 5.2ms | Allocations: 555)
Completed 200 OK in 53ms (Views: 19.5ms | ActiveRecord: 0.3ms | Allocations: 4842)
  TRANSACTION (0.1ms)  rollback transaction

解決方法

解決方法はいくつかある気もしますが、before 節で touchupdated_at 設定し直してテストを通せるようにしました。

require 'rails_helper'

RSpec.describe 'Articles', type: :request do
  describe 'GET /index' do
    let!(:newer_article) { create :article }
    let!(:older_article) { create :article }

    before do
      newer_article.touch(time: Date.new(2024, 6, 20))
      older_article.touch(time: Date.new(2024, 6, 19))
    end

    it do
      get '/articles/index.json'
      response_json = response.parsed_body

      p '=== newer_article ==='
      pp newer_article
      p '=== older_article ==='
      pp older_article
      p '=== response ==='
      pp response_json

      expect(response_json[0]['id']).to eq older_article.id
      expect(response_json[1]['id']).to eq newer_article.id
    end
  end
end
$ rspec
"=== newer_article ==="
#<Article:0x00007effc4a71000
 id: 1,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 04:11:57.994704000 UTC +00:00,
 updated_at: Thu, 20 Jun 2024 00:00:00.000000000 UTC +00:00,
 image_data:
  "{\"id\":\"a1cba966174f7aa752a50a9fa3ae1a82.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}">
"=== older_article ==="
#<Article:0x00007effc471e4c0
 id: 2,
 title: "test title",
 body: "sample body",
 created_at: Sun, 23 Jun 2024 04:11:58.064807000 UTC +00:00,
 updated_at: Wed, 19 Jun 2024 00:00:00.000000000 UTC +00:00,
 image_data:
  "{\"id\":\"00179feb51547ac848c0f45bc7f25243.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"test.png\",\"size\":10084,\"mime_type\":null}}">
"=== response ==="
[{"id"=>2, "title"=>"test title", "body"=>"sample body"},
 {"id"=>1, "title"=>"test title", "body"=>"sample body"}]
.

Finished in 0.42006 seconds (files took 5.13 seconds to load)
1 example, 0 failures

まとめ

Shrine を使った際に、 updated_at に依存したテストがうまくいかなくなる問題を調査・解消しました。

Shrine を使った画像アップロードを行うテストで、updated_at を参照する場合、画像アップロードの段階で UPDATE 文が走るため、updated_at が更新されてしまうため、別途 updated_at を調整する必要がありそうです。

PlantUMLはいいぞ

はじめに

ここ数年、シーケンス図やアクティビティ図などを書く際に PlantUML を使って書くようにしています。

plantuml.com

Excel や drawio を使うより個人的に合っていたので、その紹介記事となっています。

※注意

似たような用途のツールに Mermaid などがあるかと思いますが、それらとの比較は行っていません。

あくまで、比較的自由に図を配置できるもののテキストベースでの管理が難しいと思われるツール群と比較して、個人的に好きなポイントを挙げる記事となっています。

PlantUML とは

PlantUML はテキストベースで UML 図をはじめとした図を作成できるツールです。

どんな図が書けるか

2024年5月時点でサポートしている図は以下のようになっています。

まだ使ったことはないのですが、ER 図もあるのが嬉しいです。

  • UML
    • シーケンス図
    • ユースケース図
    • クラス図
    • オブジェクト図
    • アクティビティ図(※2024年5月時点で β 版)
    • コンポーネント図
    • 配置図
    • 状態図
    • タイミング図
  • 非UML
    • JSON データ
    • YAML データ
    • EBNF 図
    • 正規表現ダイアグラム
    • ネットワーク図
    • UI モックアップ
    • ArchiMate
    • Ditaa ダイアグラム
    • ガントチャート
    • 年表
    • マインドマップ
    • WBS
    • AsciiMath または JLaTeXMath 記法による数式
    • ER 図
    • エンティティ関係図

どのように記述してくのか

図によってコードの記述方法は変わるものの、難解なものでなければ手軽に図を作成できます。

たとえば、以前メールの仕組みについて調べた記事で作成した以下のシーケンス図は、次のようなコードで記述されています。

@startuml SMTP のシーケンス図
  title: SMTPでのメールの送信
  participant client [
    クライアント
    ----
    ドメイン:mail.sender.com
  ]
  participant server [
    メールサーバー
    ----
    ドメイン:mail.receiver.com
  ]

  note over client
    送信者:[email protected]
    受信者:[email protected]
    メール本文:
      Hello, World.
      Goodbye, World.
  end note

  client -> server ++: EHLO mail.sender.com 【CRLF】\n(通信開始)
  return 250 【CRLF】
  note right: 250:要求された処理を完了した

  client -> server ++: MAIL FROM:<[email protected]> 【CRLF】\n(送信者の指定)
  return 250 【CRLF】

  client -> server ++: RCPT TO:<[email protected]> 【CRLF】\n(受信者の指定)
  return 250 【CRLF】

  client -> server ++:DATA 【CRLF】\n(電子メール本文の送信開始)
  return 354 【CRLF】
  note right: 354:電子メールの入力開始\n(ピリオドのみの行があれば入力終了とみなす)

  client -> server ++:Hello, World.【CRLF】Goodbye, World.【CRLF】.【CRLF】\n(メール本文)
  return 250 【CRLF】

  client -> server ++:QUIT【CRLF】\n(終了)
  return 221 【CRLF】
  note right: 221:サービスを終了する
@enduml

また、この図の作成は VS Code 上でも行うことができ、以下の拡張機能を導入することで VS Code 上で出力結果を確認しながらコードを記述できます。

marketplace.visualstudio.com

出力はどのようなものがあるか

作成した図は PNG や SVG などの画像形式で保存することもできます。さらに、シーケンス図に限ってはアスキーアート形式での出力に対応しているようです。

何が良いのか?

個人的な好きポイントを列挙します。

  • 難しくない構造ならいい感じに整えてくれる
  • レビュー・変更管理しやすい
  • 日本語のドキュメントがある
  • Docker Image が公式で用意されている

さらに、これらの特徴を少し掘り下げていきます。

難しくない構造ならいい感じに整えてくれる

私は、ドキュメント作成において、図の完成度を上げるための配置・フォントなどこだわりは少ないほうです。むしろ、フォントサイズを合わせたり、思い通りの配置にならない図形と戦うことが嫌いなタイプです。そのため、その辺りをおまかせにして読める資料ができるツールという観点でかなりポイントが高いです。

レビュー・変更管理しやすい

テキストベース(というかコード)なので、GitHub で気になった部分へコメントをつけるという形で、簡単にレビューを行うことができます。また、テキストベースなので人が読める形式で差分確認が可能です。

日本語のドキュメントがある

やはり、母語でドキュメントがあると導入のハードルがグッと下がります。

plantuml.com

また翻訳がまだされていないページもあるようですが、各図のためのコードの書き方に当たるページはおおよそ翻訳済みのため、手元の環境を一度整えてしまうか、公式が用意しているオンラインサーバーを使えば、おおよそ問題なくコードを書くことができます。

www.plantuml.com

Docker Image が公式で用意されている

ローカルインストールの場合、Java をインストールしておく方法が紹介されていますが、それとは別に以下に公式の Docker Image が用意されています。

https://hub.docker.com/r/plantuml/plantuml-serverhub.docker.com

環境構築方法

ここからは VS Code で、PlantUML の Docker Image を使って図を書くために私が行った環境構築の方法を共有します。

Mac OS で環境構築手順を説明しますが、Windows でも WSL 上で Docker を使えるようにして同様に VS Code 上で環境構築可能です。

また、本記事では Docker、VS Code、拡張機能の入れ方、各種コマンドの意味までは追わないのでご了承ください。

また、環境構築は以下を参考に行なっています。

plantuml.com

1. PlantUML の Docker コンテナを起動する

以下のコマンドで PlantUML の Docker コンテナを起動します。こだわりはないので、ポートは公式が指定している通り、8080 にしています。

Docker Image がローカルにない場合は自動で pull してくれます。

$ docker run -d -p 8080:8080 plantuml/plantuml-server:jetty
Unable to find image 'plantuml/plantuml-server:jetty' locally
jetty: Pulling from plantuml/plantuml-server
3dd181f9be59: Already exists 
0f838805bddf: Already exists 
e7eee5bc80e6: Already exists 
51526e7965d8: Already exists 
ffcdc7c6c160: Already exists 
db9ff886aa2a: Already exists 
ea68cb3a87c4: Already exists 
53bb26de497a: Pull complete 
7faa3863d205: Pull complete 
7cad14e06837: Pull complete 
03450c38dc62: Pull complete 
4f4fb700ef54: Pull complete 
317d65c479db: Pull complete 
759761dc299b: Pull complete 
a32ba9471513: Pull complete 
8c453cf101d4: Pull complete 
Digest: sha256:215d49a0f5d9e5d55f8f6ea7bd8b23a0873625e7e0f886d78c213ebca27584b0
Status: Downloaded newer image for plantuml/plantuml-server:jetty
107a695c5f7184f953986c4ecd66e9e733293359f6fa2b2f43cf3eeaa3e562ca

ブラウザで http://localhost:8080 にアクセスして、以下のような画面が表示されれば成功です。

とくにエディターにこだわりがない、コードを保持する必要がない場合、左側の入力欄の入力が即座に右側の図に更新がかかる環境で作業可能です。

2. VS Code の拡張機能を入れる

VS Code で、拡張機能「PlantUML」をインストールします。

以下の URL からもインストール可能です。

marketplace.visualstudio.com

3. 拡張機能の設定

設定画面を開き、以下のように PlantUML の拡張機能の設定をします。

項目 設定値
Plantuml: Render PlantUMLServer
Plantuml: Server http://localhost:8080/(1. でアクセスした URL)

動作確認のため、VS Code 上で以下の内容で test.wsd というファイルを作成します。

@startuml
Bob -> Alice: Hello
@enduml

Options + D でプレビュー画面を開き、以下のような画面が表示されれば問題ないかと思います。

今回は .wsd の拡張子を指定していますが、2024年5月現在でサポートされている拡張子は、 *.wsd, *.pu, *.puml, *.plantuml, *.iuml のため、これらの拡張子であれば問題なく動作可能かと思います。最新の情報は PlantUML の拡張機能ページ内「Supported Formats」の項目を参照ください。

marketplace.visualstudio.com

まとめ

テキストベースで UML 図を作成できる PlantUML の特徴と VS Code でコードを技術するための導入方法を紹介しました。

日本語ドキュメントが存在するという観点で、この手のツールのとっかかりとして扱いやすいのではないかと思います。