年齢の算出

年齢の算出は簡単にできるかと思いきや、少々面倒臭い。これは、閏年というものが存在するためである。そのため以下のような単純な時間のdiffで算出しようとすると年を重ねる日時が1日ほどズレる場合がある。

const today = new Date(2010, 2, 10); // 2010-03-10
const birthday = new Date(2000, 2, 10); // 2000-03-10
const diff = today.getTime() - birthday.getTime();
const age = Math.floor(diff / (1000*3600*24*365.25));
console.log(age); // 9

また、誕生日が閏日(02-29)の人は、閏年以外は うるう年をめぐる法令|参議院法制局 によると、03-01 が誕生日となるのでこれも考慮すると以下のようになる。だいたいの日付オブジェクトでは閏年以外で 02-29 を指定すると 03-01 と解釈されるので、閏日の時に条件分けする必要はない。

// const today = new Date(2010, 1, 29); // 2010-03-01, 閏年以外で 02-29 を指定すると 03-01 と解釈される
const today = new Date(2010, 2, 10); // 2010-03-10
const birthday = new Date(2000, 2, 10); // 2000-03-10

let age = today.getFullYear() - birthday.getFullYear();
if (today.getMonth() < birthday.getMonth()) {
  age--;
}
else if (today.getMonth() === birthday.getMonth() && today.getDate() < birthday.getDate()) {
  age--;
}
console.log(age); // 10

ここで、上記で参照した うるう年をめぐる法令|参議院法制局 によると道路交通法では閏日の人は 02-28 が誕生日と解釈されるので道路交通法関連で年齢を算出するときは注意が必要となる。また日本国外では別のルールに基づく可能性があるので合わせて注意が必要となる。

Minecraft Java Edition 1.20 + PaperMC + Multiverse-Core 環境での砂無限増殖装置(Sand Duper)

エンドポータルとチャンクローダーを利用した砂無限増殖装置というのがある(通称: Sand Duper)。以下の動画を参考に作ったが、PaperMC + Multiverse-Core という環境だと動作しなかった。

問題と解決方法

問題点は以下のとおりでこれらを解決する必要がある。

  1. Sand Duper が動作しない
  2. チャンクローダーが動作しない
  3. エンドポータルを通った砂が別の箇所にスポーンする

Sand Duper が動作しない

まず、そもそも砂無限増殖装置の要である Sand Duper が動作しない(粘着ピストンでガチャガチャするとブロックが増殖する現象)。これは PaperMC で修正されてしまっているためである。これを解決するためには GravityControl というModを導入する。設定は特に必要ない。

GravityControl - Minecraft Plugin

チャンクローダーが動作しない

次にチャンクローダーが動作しないのでエンドに行ったり遠くに行くとSand Duperの装置が停止してしまう。どうもPaperMC環境だと動作しないチャンクローダーがある模様。そのため、PaperMCでも動作するチャンクローダーとして以下があるのでこれに置き換える。

エンドポータルを通った砂が別の箇所にスポーンする

エンドポータルを通った砂が別の箇所にスポーンする。これは Multiverse-Core が原因で、転送された砂が worlds.yml に記述されているエンドの spawnLocation で指定された座標にスポーンしてしまうためである(なぜかプレイヤーは影響を受けない。リスポーンするときはここになる)。

これを解決するためには、spawnLocation をエンドポータルで転送される座標に指定する必要がある。なお、このとき横方向の座標は+0.5しないと微妙にズレてうまくいかない。これはおそらく座標はブロックの端を表すが、エンドポータルで転送される座標はエンティティの中心をもとにしているせいかもしれない。また縦方向に+1するといいかもしれない。

plugins/Multiverse-Core/worlds.yml:

worlds:
  world_the_end:
    spawnLocation:
      ==: MVSpawnLocation
      x: 100.5
      y: 50.0
      z: 0.5
      pitch: 0.0
      yaw: 0.0

なお、別件だが Multiverse-Core ではデフォルトではリスポーン位置はそのディメンジョンになるので、worlds.yml のエンドの respawnWorldworld に変更しておかないとベッドを使っていない場合エンドから出れなくなるので注意が必要。

plugins/Multiverse-Core/worlds.yml:

worlds:
  world_the_end:
    respawnWorld: world

最後に

これでようやく砂無限装置が動作するようになる。なお、砂無限増殖装置を作成するときにエンドポータルフレームを破壊するが、PaperMC では unsupported-settings.allow-permanent-block-break-exploits の設定を true にしないと破壊できないので注意が必要(デフォルト false)。ほかにもこういった怪しい動作が修正されていたりするので、unsupported-settings 以下の設定は有効にしておくとよい。

config/paper-global.yml:

unsupported-settings:
  allow-grindstone-overstacking: true
  allow-headless-pistons: true
  allow-permanent-block-break-exploits: true
  allow-piston-duplication: true

動作環境

  • Minecraft:
  • Server:
    • PaperMC: 1.20.1
  • Mod:
    • Multiverse-Core: 4.3.1
    • GravityControl: 2.0.0

overscroll-behavior を使ってダイアログを開いている時はページのスクロールをしないようにする

まず以下のダイアログを考える。このダイアログはダイアログ内のコンテンツが一定の高さに到達するとスクロールバーが出るようになっている。

<div class="content">
  <button class="open-button" type="button">open modal</button>

  <p>メッセージ1</p>
  <p>メッセージ2</p>
  ...

  <div class="modal">
    <div class="modal-content">
      <div class="modal-header">
        <h2>Modal Header</h2>
      </div>
      <div class="modal-body">
        <p>メッセージ1</p>
        <p>メッセージ2</p>
        ...
      </div>
      <div class="modal-footer">
        <button>OK</button>
        <button>Cancel</button>
      </div>
    </div>
  </div>
</div>
* {
  box-sizing: border-box;
}

.modal {
  display: block;
  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  opacity: 0;
}
.modal.open {
  z-index: 1000;
  opacity: 1;
}
.modal::after {
  display: block;
  position: absolute;
  z-index: 1;
  content: ' ';
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
  display: flex;
  flex-direction: column;
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 80%;
  border: 1px solid #888;
  background-color: #fff;
}
.modal-header {
  width: 100%;
}
.modal-body {
  width: 100%;
  height: 200px;
  overflow-y: auto;
}
.modal-footer {
  width: 100%;
  margin-top: auto;
}

しかし、ダイアログの中でスクロールしているときにダイアログのスクロールバーが端に到達するとページのスクロールバーが動いてしまう。また、ホイールしたときにポインターがダイアログ外にあるときもページのスクロールバーが動いてしまう。

この動作を防ぐためには、overscroll-behavior プロパティを使って、デフォルトの動作を変える必要がある。overscroll-behavior にはいくつか指定できるが、none を指定することで、親要素のスクロールバーにスクロール操作が伝搬することを無効にすることができる。

しかし、overscroll-behavior プロパティは以下の状態でしか指定が有効にならない。

  1. 要素のスクロールバーが表示されている
  2. 要素のスクロールバーのバーが表示されている

1. については要素に overflow-y: scroll を指定することで解決できる。しかし、これが指定されただけだと要素のコンテンツが要素内に収まる場合はスクロールバーのバーが表示されないため 2. の条件が有効にならない。

これを解決するためには、要素のコンテンツ、つまり子要素の高さの合計が、100% を超える必要がある。これを実現する手っ取り早い方法が height: calc(100% + 1px); で、常に親要素より 1px だけ高くなるので、スクロールバーのバーが表示されるようになる。

これを踏まえて上記のダイアログを改良する。方針としてはダイアログのレイヤー全体.modaloverscroll-behavior: none; を指定し、その子要素、ここではダイアログの背景.modal::afterheight: calc(100% + 1px); を指定する。また、ダイアログの本文.modal-bodyには overscroll-behavior: contain; を指定するとスマホで端に到達したときにスクロールバーがバウンスするデフォルトの動作をするようになるので、こちらも指定する。

...
.modal {
  display: block;
  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
+  overflow-y: scroll;
+  overscroll-behavior: none;
  opacity: 0;
}
.modal::after {
  display: block;
  position: absolute;
  z-index: 1;
  content: ' ';
  top: 0;
  left: 0;
  right: 0;
-  bottom: 0;
+  height: calc(100% + 1px);
  background-color: rgba(0, 0, 0, 0.4);
}
...
.modal-body {
  width: 100%;
  height: 200px;
  padding: 15px;
  overflow-y: auto;
+  overscroll-behavior: contain;
}
...

これで、ダイアログのスクロールバーが端に到達してもページのスクロールバーが動かなくなった。ここでこの方法のデメリットとして以下がある。

  1. ダイアログレイヤー.modalにスクロールバーが常に表示される
  2. ダイアログレイヤー.modalが1pxスクロールできてしまうので、場合によっては微妙にカクつく

この動作が気になる場合はこの方法を使うことはできないが、基本的に問題ないはず。

また、overscroll-behavior は以下の環境以外では使用できないが、この場合でも親要素のスクロールバーが動くだけなので致命的な問題ではないはず。

なお、この方針で実装したサンプルが以下にある。

参考

Rails + PostgreSQL でデータベースを作り直す

PostgreSQLでは、他のセッションが同じdatabaseに接続しているときは DROP DATABASE できないようになっている。

my_database=# DROP DATABASE my_database;
ERROR:  database "my_database" is being accessed by other users
DETAIL:  There is 1 other session using the database.

そのため、Rails で以下のようにデータベースを作り直すときにしばしば問題になる。

$ rails db:drop db:setup
PG::ObjectInUse: ERROR:  database "my_database" is being accessed by other users
DETAIL:  There is 1 other session using the database.
Couldn't drop database 'my_database'
rails aborted!
ActiveRecord::StatementInvalid: PG::ObjectInUse: ERROR:  database "my_database" is being accessed by other users (ActiveRecord::StatementInvalid)
DETAIL:  There is 1 other session using the database.


Caused by:
PG::ObjectInUse: ERROR:  database "my_database" is being accessed by other users (PG::ObjectInUse)
DETAIL:  There is 1 other session using the database.

Tasks: TOP => db:drop:_unsafe
(See full trace by running task with --trace)

これを解決するためには、他のセッションを切断してから再度 DROP DATABASE すればいいが、以下のSQLを実行することでも切断することができる。

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'my_database' AND pid <> pg_backend_pid();
my_database=# SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'my_database' AND pid <> pg_backend_pid();
 pg_terminate_backend
----------------------
 t
(1 row)

そのため、以下のように他セッションの接続を切断する Rake task を作成し実行すれば他のセッションを気にすることなく作成しなおすことができるようになる。

lib/tasks/db/connection.rake:

namespace :db do
  namespace :connection do
    desc 'Close all DB connections'
    task :close => :environment do
      db_name = ActiveRecord::Base.connection.current_database
      ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array([<<~SQL, db_name]))
        SELECT pg_terminate_backend(pid)
        FROM pg_stat_activity
        WHERE datname = '%s' AND pid <> pg_backend_pid();
      SQL
    end
  end
end
$ rails db:connection:close db:drop db:setup
Dropped database 'my_database'
Created database 'my_database'

環境

参考

Docker container で sleep infinity を使う時は init プロセスを有効にする

Docker container で sleep infinity を使う時は init プロセスを有効にしないとSIGINTなどのシグナルの処理がうまくできずにハングするので、これを使う時は有効にする必要がある。docker compose の compose.yaml ではinit: true の指定が init プロセスを有効にするオプションとなる。

compose.yaml:

---
services:
  app_base:
    build:
      context: .
      dockerfile: Dockerfile
    init: true
    command: sleep infinity

axios でリクエストログを出力する

JavaScriptでXHRやFetchなどで通信するとブラウザの開発コンソールのネットワークなどにログが出力されますが、Next.js のSSRなど、Node.js で実行する場合はログはでないです。そのため、通信ログが欲しい場合は、なんらかの方法でログを出力する必要がありますが、axios を使用している場合、以下のようにすると通信ログが出力されるようです。

import axios from 'axios';

axios.interceptors.request.use((request) => {
  console.log('Starting Request: ', request);
  return request;
});

axios.interceptors.response.use((response) => {
  console.log('Response: ', response);
  return response;
});

axios.get("https://example.com");

簡易的な確認の場合、これでもいいのですが少々見にくいので以下のように整形すると見やすいです。

import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';

/**
 * JSONを整形する場合は`true`にする
 */
const pretty = false;

/**
 * `axios`で通信するときにログを出力する
 * @notice 秘匿情報のマスクなどは行っていないので、ローカル環境でのみ有効にするかマスク処理を追加する。
 */
export function injectAxiosLogger(instance: AxiosInstance): AxiosInstance {
  if (process.env.NODE_ENV === 'production') return instance;
  const bar = [...times(120)].map((_) => '-').join('');

  instance.interceptors.request.use((request) => {
    const req = dumpAxiosRequestConfig(request);
    console.log(`${bar}\nStarting HTTP Request\n${bar}\n${req}\n${bar}`);
    return request;
  });

  instance.interceptors.response.use((response) => {
    const req = dumpAxiosRequestConfig(response.config);
    const res = dumpAxiosResponse(response);
    console.log(
      `${bar}\nHTTP Request Completed\n${bar}\n${req}\n${bar}\n${res}\n${bar}`
    );
    return response;
  });

  return instance;
}

/**
 * HTTPリクエストをダンプする
 * @see AxiosRequestConfig https://github.com/axios/axios?tab=readme-ov-file#request-config
 * @see https://sabljakovich.medium.com/axios-interceptors-log-request-and-response-72b01333a760
 */
export function dumpAxiosRequestConfig(req: AxiosRequestConfig) {
  const method = req.method?.toLocaleLowerCase() || '';
  const baseURL = req.baseURL || '';
  const path = req.url || '';
  const params =
    req.params instanceof URLSearchParams
      ? req.params
      : new URLSearchParams(req.params || {});
  let headers = req.headers || {};
  headers = {
    ...(headers.common || {}),
    ...(headers[method] || {}),
    ...headers,
  };
  for (let h of ['common', 'get', 'post', 'head', 'put', 'patch', 'delete']) {
    delete headers[h];
  }
  const data = req.data;

  const rawParams = params.toString();
  const rawHeaders = Object.entries(headers)
    .map(([k, v]) => `${k}: ${v}`)
    .join('\n');
  const rawData = dumpData(data);
  let msg = `${method.toUpperCase()} ${baseURL}${path} HTTP/1.1`;
  msg += rawParams ? `?${rawParams}` : '';
  msg += rawHeaders ? `\n${rawHeaders}` : '';
  msg += rawData ? `\n\n${rawData}` : '';
  return msg;
}

/**
 * HTTPレスポンスをダンプする
 * @see AxiosResponse https://github.com/axios/axios?tab=readme-ov-file#response-schema
 */
export function dumpAxiosResponse<T>(res: AxiosResponse<T>) {
  const status = res.status;
  const statusText = res.statusText;
  const headers = res.headers || {};
  const data = res.data;

  const rawHeaders = Object.entries(headers)
    .map(([k, v]) => `${k}: ${v}`)
    .join('\n');
  const rawData = dumpData(data);

  let msg = `HTTP/1.1 ${status} ${statusText}`;
  msg += rawHeaders ? `\n${rawHeaders}` : '';
  msg += rawData ? `\n\n${rawData}` : '';
  return msg;
}

function *times(n: number) {
  for (let i = 0; i < n; i++) yield i;
}

function dumpData(data: any) {
  return data
    ? typeof data === 'object'
      ? pretty
        ? JSON.stringify(data, null, ' ')
        : JSON.stringify(data)
      : data
    : '';
}

実行結果:

------------------------------------------------------------------------------------------------------------------------
Starting HTTP Request
------------------------------------------------------------------------------------------------------------------------
GET https://example.com HTTP/1.1
Accept: application/json, text/plain, */*
Content-Type: undefined
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
HTTP Request Completed
------------------------------------------------------------------------------------------------------------------------
GET https://example.com HTTP/1.1
Accept: application/json, text/plain, */*
Content-Type: undefined
User-Agent: axios/1.6.0
Accept-Encoding: gzip, compress, deflate, br
------------------------------------------------------------------------------------------------------------------------
HTTP/1.1 200 OK
age: 252936
cache-control: max-age=604800
content-type: text/html; charset=UTF-8
date: Mon, 06 Nov 2023 10:42:07 GMT
etag: "3147526947+gzip"
expires: Mon, 13 Nov 2023 10:42:07 GMT
last-modified: Thu, 17 Oct 2019 07:18:26 GMT
server: ECS (laa/7BA2)
vary: Accept-Encoding
x-cache: HIT
content-length: 648

<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        
    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 2em;
        background-color: #fdfdff;
        border-radius: 0.5em;
        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        div {
            margin: 0 auto;
            width: auto;
        }
    }
    </style>    
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

------------------------------------------------------------------------------------------------------------------------

これでaxiosの通信ログを出力できるようになりましたが、使用するにあたっては注意が必要で、パスワードやアクセストークンなどの秘匿情報もログに出力されてしまうので、ローカル環境以外は無効にするか、マスク処理を実行する必要があります。

また、上記のサンプルコードは sandbox/ts-axios-logger · mrk21/sandbox にあります。

TypeScript の definite assignment assertion operator の話

TypeScript の class では、プロパティは初期化指定子かコンストラクタで初期化していないとエラーになります。そのため、以下のように記述する必要があります。

class Hoge {
  value1: string = 'a';
  value2: number;
  // value3: boolean; // ERROR: Property 'value3' has no initializer and is not definitely assigned in the constructor.

  constructor() {
    this.value2 = 1;
  }
}

しかし、たとえば Vue.js の Component class の props のように Vue.js 側で初期化するプロパティについては少々困ります。この確実に初期化されるが初期化指定子やコンストラクタで初期化はしないという状況の時に、明確な割り当てアサーション演算子(definite assignment assertion operator) ! 1を使用することでこの問題を回避できます。

@Component
export default class HogeComponent extends Vue {
  @Prop({ type: String, required: true }) value1!: string;
  @Prop({ type: Number, required: true }) value2!: number;
  @Prop({ type: Boolean, required: true }) value3!: boolean;
}