Practice of Programming

プログラム とか Linuxとかの話題

OpenSCADでDIY(家具とか)の設計をする

別に技術系の話ではないのですが、こっちの素養がある方には馴染みやすいツールじゃないかなと思って、こっちに書いてます。

OpenSCADって?

OpenSCADは、3D CADモデラーです。

openscad.org

僕はUbuntuで使っていますが、macOSでも、Windowsでも使えます。

どういうふうに使うのか?

僕は他のCADを使ったことはありませんので、まったく比較とかはできませんが、GUIでぐりぐり動かすような感じのものでは、全く無いです。

プログラム的な感じで、形を定義して、配置して、組み合わせた結果を図として表示してくれるものです。

この記事で書かれていること

今回紹介する機能は、OpenSCADのほんの一部の機能です。あくまでDIYで家具類を作るための最低限の紹介になります。

複雑な構造や、3Dプリンターとかの話は範囲外なので、期待した方は残念ながら対象外です。

百聞は一見にしかず

ということで、実際のコードと図を例示します。これ、最初の頃によくわからずに作ったものなので、愚直にものを定義していっちゃってますが、後で直します。

// left side
translate([0,0,0]) {
    cube([23,2.7,44]);
}

// right side
translate([0,25.7,0]) {
    cube([23,2.7,44]);
}

// back
translate([0,2.7,6.3]) {
    cube([2.7,23,35]);
}

// front 
translate([23,5.2,2.4]) {
    cube([2.7,18,39]);
}

// front left
translate([23,0,2.4]) {
    cube([2.7,5.2,39]);
}

// front right
translate([23,23.2,2.4]) {
    cube([2.7,5.2,39]);
}

// front botttom
translate([23,-0.25,-0.25]) {
    cube([2.7,29,2.7]);
}

// front top
translate([23,-0.25,41.4]) {
    cube([2.7,29,2.7]);
}

// top
translate([0,2.7,41.3]) {
    cube([23,23,2.7]);
}

// bottom
translate([0,2.7,3.6]) {
    cube([23,23,2.7]);
}

上記のコードで、下記のような図ができあがります。

なお、この画像ではわかりませんが、底面に隙間を作っています。キャスターを取り付けるために空けています。

簡単に説明

cube というのは、直方体を定義できます。cube([23,2.7,44]) と書かれているのは、x: 23cm, y: 2.7cm, z: 44cm という意味で使っていますが、単位は、僕が勝手にそう決めているだけなので、230にして、mm としても別に問題ないです。

translateは、その中に書かれているものをどこに配置するか、translate([0,0,0])であれば、中心から配置しますし。translate([0,25,7,0]) であれば、y方向に25.7の位置に配置するということです。

なお、このコードだと、x: 奥行き、y: 幅、z: 高さ という感じになっています(z は大丈夫でしょうが、x,y は、気分で作ると、毎回ちがってたりします)。

moduleを使うべき

先程のものでも、ちゃんと図ができあがるのですが、同じcubeの定義がいくつもあるのが、わかるかと思います。全然いけてません。

moduleとして定義すると良いです。

module side_wall() { // 2枚
    cube([23,2.7,44]);      
}

module front_top_bottom() { // 2枚
    cube([2.7,29,2.7]);
}

module top_bottom() { // 2枚
    cube([23,23,2.7]);
}

module front_side() { // 2枚
    cube([2.7,5.2,39]);
}

module back() { // 1枚
    cube([2.7,23,35]);
}

module front() { // 1枚
    cube([2.7,18,39]);
}

// left side
translate([0,0,0]) {
    side_wall();
}

// right side
translate([0,25.7,0]) {
    side_wall();
}

// back
translate([0,2.7,6.3]) {
    back();
}

// front 
translate([23,5.2,2.4]) {
    front();
}

// front left
translate([23,0,2.4]) {
    front_side();
}

// front right
translate([23,23.2,2.4]) {
    front_side();
}

// front botttom
translate([23,-0.25,-0.25]) {
    front_top_bottom();
}

// front top
translate([23,-0.25,41.4]) {
    front_top_bottom();
}

// top
translate([0,2.7,41.3]) {
    top_bottom();
}

// bottom
translate([0,2.7,3.6]) {
    top_bottom();
}

同じサイズのものは、間違いなくmoduleにしたほうが良いですが、一つしか使わないものも、moduleにしたほうが良いです。

なぜなら、木材を注文する時に、moduleを見て買えば良いからです。なので、コメントに 2枚 とか書いておくと、良いですね。

for を使う

もう少し改善できます。for ループを使うことができます(moduleは同じなので省略します)。

// left/right side
for (i = [0,25.7]) {
  translate([0,i,0]) {
      side_wall();
  }
}

// back
translate([0,2.7,6.3]) {
    back();
}

// front 
translate([23,5.2,2.4]) {
    front();
}

// front left/right
for (i = [0,23.2]) {
  translate([23,i,2.4]) {
    front_side();
  }
}

// front botttom/top
for (i = [-0.25, 41.4]) {
  translate([23,-0.25,i]) {
    front_top_bottom();
  }
}

// top/bottom
for (i = [41.3,3.6]) {
  translate([0,2.7,i]) {
    top_bottom();
  }
}

色を付ける

module に color を付けることもできます。

module side_wall() {
    color("red")
    cube([23,2.7,44]);      
}

こんな感じで赤くなりました。

変数を使う

発注するときに面倒なので、あんまり変数を使いすぎると良くないかもしれませんが。

こんな感じで、height という変数を定義してやると、もともと高さ44cmのを作ろうと思ってたけど、もう少し高くしよう、と思ったときでも、簡単に変更できます。

height = 60;

module side_wall() {
    color("red")
    cube([23,2.7,height]);      
}

module front_top_bottom() {
    cube([2.7,29,2.7]);
}

module top_bottom() {
    cube([23,23,2.7]);
}

module front_side() {
    cube([2.7,5.2,height - 5]);
}

module back() {
    cube([2.7,23,height - 9]);
}

module front() {
    cube([2.7,18,height - 5]);
}

// left/right side
for (i = [0,25.7]) {
  translate([0,i,0]) {
      side_wall();
  }
}

// back
translate([0,2.7,6.3]) {
    back();
}

// front 
translate([23,5.2,2.4]) {
    front();
}

// front left/right
for (i = [0,23.2]) {
  translate([23,i,2.4]) {
    front_side();
  }
}

// front botttom/top
for (i = [-0.25, height - 2.6]) {
  translate([23,-0.25,i]) {
    front_top_bottom();
  }
}

// top/bottom
for (i = [height - 2.7,3.6]) {
  translate([0,2.7,i]) {
    top_bottom();
  }
}

鉄のシェルフブラケットを表してみる

箱から離れまして、よく、壁に取り付ける「シェルフブラケット」と呼ばれるものがあるんですが、下記のようなものです。

item.rakuten.co.jp

下記のようなmoduleで定義できます。

module bracketInner(){
    cube([21,1.2,7]);
}

module bracket() {
  color("black")
  difference () {
    cube([22.5,1,57]);
    for (i = [0.8:8:56]) {
      translate([0.5,-0.1,i]) bracketInner();
    }
  }
}

cube で大きな、板を作って、for で回して、angleInnerを、difference(2番目以降の子ノードを減算する)を使って抜いています(angleInnerを少し大きくしておく必要があるので、1 に対して、1.2となっています)。

ちなみに実際の箱

こんな感じで出来上がっています。接続部分は、鉄のアングルで、内側から止めています。

DIY素材の購入

大体のサイトで、cm単位で木を切った状態で送ってくれるので、今回の箱とかでは、鋸刃一切使っていません。ので、滅茶簡単です。

箱の素材は、下記で購入しました。

www.woodpro21.com

ここの素材は、サイズに割と制限があって、設計してると、パズル作ってるみたいになってしまって微妙なときがあるんですが、よく使っています。

自由なサイズで注文するなら、下記のサイトとかが良いと思います。簡単な加工も依頼できますし、希望すれば端材をもらえます。

https://diy-lab.jp/

終わり

DIYするときは、割と簡単なものでも、OpenSCADで設計するようにしています。紙に書くより正確だし変更が容易。moduleを作っておけば、注文するものがわかりやすい。3Dの図としてできあがると完成イメージが湧くので良いです。

また、大きなものだと、作成する以外の物(冷蔵庫とか、既存の棚、天井とか)とかもOpenSCADで表現してやると良いと思います。

例として、以下は、うちのキッチンに棚を作るときに用意したものです。

天井の段差、冷蔵庫、電子レンジ、食器棚、蓋をあけた炊飯器とかを雑に表現していますが、これくらいでも作っておけば、随分イメージがわきます。思ったよりものが入りそうにないなーとか...。

というわけで、OpenSCAD非常におすすめですよ。

参照

OpenSCADのマニュアルは下記にあります。

ja.wikibooks.org

Nanocで全文検索を実装する

いくつかNanocの記事を書きましたが、検索がないと、実際問題使えないですよね。

というわけで、Googleで検索してみましたが、下記が見つかるくらいでした。

groups.google.com

上記からたどったところ、下記のようなものがあれば、なんとかなりそうというところですが...... 元にしているNanocのバージョンが古いのと、英語のみを対象としているようなので、これを元に自作してみました。検索の実装自体はあまり変えていません。

github.com

github.com

lib/search.rb

検索用のindexを作るRubyスクリプトです。

# coding: utf-8
require 'json'
require 'nokogiri'

# 下記は適当に変えてください
$ROOT_PATH = ENV.fetch("SITE_ROOT"){Dir.getwd + '/output'}
# search-data.js.erbのMAX_WORD_LENと合わせる
$MAX_WORD_LEN = 3;

# item が一つのドキュメント
def search_terms_for(item)
  # md 以外は除外
  if item.identifier != nil && item.identifier.ext != nil && item.identifier.ext == 'md'
    # HTMLになった内容を取得
    content = item.reps.fetch(:default).compiled_content
    doc = Nokogiri::HTML(content)

    # 対象とするタグが足りなければ増やしてください
    full_text = doc.css("td, pre, div, p, h1, h2, h3, h4, h5, h6").map{|el| el.inner_text}.join("").gsub(/ /, '')
    # $MAX_WORD_LEN文字ずつに区切った組み合わせの文字をindexとしています
    "#{item.identifier.without_ext}#{item[:title]}#{item[:meta_description]}#{full_text}".each_char.each_cons($MAX_WORD_LEN).map{|chars| chars.join }
   end
end

def search_index
  # 後で、idとページ情報を紐付けるのに使います
  id = 0;
  idx = {
    "approximate" => {},
    "terms" => {},
    "items" => {}
  }
  if @items == nil
    return idx
  end
  @items.each do |item|
    # 上で定義した、search_terms_forを使って、$MAX_WORD_LEN ごとに区切られた単語の配列を取得
    result = search_terms_for(item)
    if result == nil
      next
    end
    result.each do |term|
      # termにマッチする id を入れておく入れ物
      idx["terms"][term] ||= []
      idx["terms"][term] << id
      # wor がtermの場合、 w, wo も検索対象とするための処理
      (0...term.length - 1).each do |c|
        # term の 0番目からc番目までを、subtermとします
        subterm = term[0...c]
        idx["approximate"][subterm] ||= []
        unless idx["approximate"][subterm].include?(id)
          idx["approximate"][subterm] << id
        end
      end
    end
    url = $ROOT_PATH + "#{item.identifier.without_ext}"+".html"
    # items に id と title, url等の情報を紐付けて持っておきます
    idx["items"][id] = {
      "url" => url,
      "title" => item[:title] || item.identifier.without_ext,
      "crumb" => item[:crumb]
    }
    id += 1
  end
  idx
end

content/search-data.js.erb

このJavaScriptの一番最後にある、<%= search_index.to_json %>; で、lib/search.rbsearch_index から返されたものをJSONに変えて、埋め込んでいます。

埋め込まれるのは、具体的には、下記のようなJSONですね。

{
  "approximate": {"w": [0,1,2], "wo": [0,1,2]}, 
  "terms":{"wor": [0,1,2]},
  "items": {
    "0": {"url":"/path/to/page1", "title1": "title1",
    "1": {"url":"/path/to/page2", "title2": "title2",
    "2": {"url":"/path/to/page3", "title3": "title3"
  }
}

実際に検索を行うJavaScriptの実装です。

// lib/search.rb の $MAX_WORD_LEN と合わせる
const MAX_WORD_LEN = 3

function unique(arrayName)
 {
    let newArray = new Array();
    label: for (let i = 0; i < arrayName.length; i++)
    {
        for (let j = 0; j < newArray.length; j++)
        {
            if (newArray[j] == arrayName[i])
            continue label;
        }
        newArray[newArray.length] = arrayName[i];
    }
    return newArray;
}

function search(query, callback) {
  let terms = $.trim(query).split(/\s+/);
  let matching_ids = null;
  let l = terms.length;
  for (let i = 0; i < l; i++) {
    let j = i
    for (;terms[j].length > MAX_WORD_LEN;) {
       // MAX_WORD_LEN文字までのindexなので、その文字数を超えた場合は、雑に別にする
       let s = terms[j]
       terms[j] = s.slice(0, MAX_WORD_LEN);
       terms.push(s.slice(MAX_WORD_LEN));
       j = terms.length - 1;
    }
  }
  for (let i = 0; i < terms.length; i++) {
    let term = terms[i];
    let exactmatch = index.terms[term] || [];
    let approxmatch = index.approximate[term] || [];
    let ids = unique(exactmatch.concat(approxmatch));
    if (matching_ids) {
      matching_ids = $.grep(matching_ids, function(id) {
        return ids.indexOf(id) != -1;
      });
    } else {
      matching_ids = ids;
    }
  }
  callback($.map(matching_ids, function(id){ return index.items[id]; }))
}

let index = <%= search_index.to_json %>;

content/search.haml

下記が、検索ページになります。

- content_for(:javascripts) do
  %script(type="text/javascript" src="/javascripts/search-data.js")
  %script(type="text/javascript" src="/javascripts/jquery.url.packed.js")
  :javascript
    $(function(){
      let getQuery = decodeURIComponent($.url.param("query"));
      let q = $.url.param("q") || getQuery;
      if ( q != '' ) {
        let query = q.replace("+"," ");
        $('input#q').attr('value', query);
        search(query, displayResults);
      }
      $('input#q').keyup(function(){
        search(this.value, displayResults);
      });
    })
    function displayResults(items) {
      if (items.length > 0) {
        let html = ""
        for (let i = 0; i < items.length; i++) {
          html += '<li><a href="'+items[i].url+'?h=' + $("#q").val().replace(' ', '%20') + '">'+items[i].title+'</a></li>';
        }
        $('ol#results').html(html)
      } else {
        $('ol#results').html("<li class='none'>Nothing found.</li>");
      }
    }
%input#q{:type => "text", :placeholder=>"Search"}

%h2 Results
%ol#results
  %li.none Please enter a search term.

実際の検索

自前のものが用意できれば良かったのですが、最初に貼っていたコードのサイトを貼っておきます。ほぼほぼ似たような感じで動きます。

compass-style.org

欠点

やむなしですが、search-data.js.erb からできあがる search-data.js は、ページ数が多いとサイズが大きくなってしまいます。 553ページほどあるところで試しましたが、18MBくらいにはなりました。

ですが、読み込んでしまえば、速いので、そこはやむなしとします。

ページが少なければ、そこまで大きくもなりませんし、問題にはならないかと思います。

Nanocで記事ごとにUUIDを付けてリンク作ってPermalinkとする

静的ファイルジェネレータの弱点(と僕が勝手に思っている)は、URL=ファイルのパスというところです。 何が困るかと言うと、移動した場合にURLが変わってしまう。

これを解消するために、記事ごとにUUIDをつけて、実際のパスへのシンボリックリンクを作り、それをPermalinkとして使えば良いということです。

メタデータを利用する

Nanocのドキュメントの上部には、メタデータを入れることができます。

---
KEY: VALUE
---

のような感じです。ここに、UUID用に

---
NANOC_UUID: "....."
---

というのを、挿入します。こうすると、コンパイル時に attributeとして使えるようになります。layoutをhamlで書いていたとすると、

@item[:NANOC_UUID]

で、参照できます。filter内であれば、下記のように参照できます。

@item.attributes[:NANOC_UUID]

こうしておけば、permalinkようのURLとして、上記のUUIDを使ったURLを作るとか、 index tree を作るような処理のなかで、通常のパスの代わりに、UUIDのリンクの方を使うようにすればよいかと思います。

コード

最初 filterで作っていたのですが、こういうのはRules内のpreprocessでやるべきでした。filter内だと、attributeの変更はできませんが、preprocessであれば、attributeの変更ができます。

preprocess do
  @items.each do |item|
    path = item.raw_filename.sub(/^.+\/content\//, "content/")
    # markdownのみ対象
    next if path != nil && !path.match(/\.md$/);

    content = ''

    File.open(path, 'r') do |f|
      content += f.read
    end

    if content.match(/^NANOC_UUID: (.+)$/)
      # すでにある場合それを作る(とり方がちょっと雑ですが)
      uuid = $1
    else
      # 新しく作成して先頭に書き込む
      uuid = SecureRandom.uuid
      if (content.match(/\A---/))
        content = content.sub(/\A---/, "---\nNANOC_UUID: " + uuid + "\n")
      else
        content = "---\nNANOC_UUID: " + uuid + "\n---\n" + content
      end

      File.open(path, "w") do |f|
        f.puts(content)
      end
      # attribute に追加する
      item.attributes[:NANOC_UUID] = uuid
    end

    if (uuid)
      link = 'output/' + uuid + '.html'
      if !File.exist?(link)
        # output のルートに、実際のパスへのリンクを作る
        File.symlink(path.sub(/^content\/(.+)\.md$/, "\\1.html"), link)
      end
    end
  end
end

結果

こんな感じのリンクが出来上がるので、permlink として、b247771c-a07a-4cb6-835d-ee8b2d461e49.html の方を使えばいい感じです。

lrwxrwxrwx 1 ktat ktat    10 Apr 29 16:01 b247771c-a07a-4cb6-835d-ee8b2d461e49.html -> index.html
-rw-r--r-- 1 ktat ktat 12169 Apr 29 16:02 index.html

元のファイルにメタデータを追加しているので、nanoc コマンドのラッパーのshell scriptかなんかをかいて、git commit するようにしとくのが良いのではないかなと思います。

元のパスをURL欄からコピーされるのを防ぎたければ、パスにUUIDが含まれていない場合は、JavaScriptで強制的に移動させれば良いんじゃないかなと思います。

注意

filter :relativize_path を使っていると、相対パスになってしまうので、元ファイルの場所がルートに存在しない場合、シンボリックリンクをルートに置いている関係上、相性が悪いです。 cssやjsは、絶対パスで指定するようにする必要があります。

Nanocで絵文字を表示する

前回の続きでNanocの話です。

honkitでは、:name: という表記で、絵文字を表記できたりしますが、Nanocではできません。 これも、自分でフィルターを簡単に書くことができます。

gemojione を使って、絵文字を表示するフィルターを作る

lib/nanoc/filters/gemojione.rb とか適当にファイルを作ればOKです。

module Nanoc::Filters
  class ChangeCodeBlock < Nanoc::Filter
    identifier :gemojione

    require 'gemojione'

    def run(content, params = {})
      index = Gemojione::Index.new
      content = content.gsub(/:([a-z0-9 _-]+?):/) {|s|
        emoji = index.find_by_name($1) || index.find_by_shortname(":" + $1 + ":")
        moji = s
        if (emoji != nil)
          moji = emoji['moji']
        end
        moji
      }

      content
    end
  end
end

Rulesで、下記のように使うだけです。

filter :gemojione

余談

しかし、gemojioneなんですが、こんな感じになっていて、

        emoji_hash["description"] = emoji_hash["name"]
        emoji_hash["name"] = key
        @emoji_by_name[key] = emoji_hash if key

なぜか、@emoji_by_name なのに、key をキーとしていて、定義上、実質、keyshortnameが同じなので、

  • find_by_name
  • find_by_shortname

にほぼ違いがないんですよね。謎い。絵文字はこんな感じで定義されています。

※ masterは調べてませんが、v3.3.0に関しては、key と shortnameは一致してそうでした。

{
  "100": {
    "unicode": "1F4AF",
    "unicode_alternates": [

    ],
    "name": "hundred points symbol",
    "shortname": ":100:",
    "category": "symbols",
    "aliases": [

    ],
    "aliases_ascii": [

    ],
    "keywords": [
      "numbers",
      "perfect",
      "score",
      "100",
      "percent",
      "a",
      "plus",
      "school",
      "quiz",
      "test",
      "exam",
      "symbol",
      "wow",
      "win",
      "parties"
    ],
    "moji": "💯"
  },
  // ...
}

Nanoc で PlantUMLを表示する

Nanoc とは?

静的サイトジェネレーターです。jekyllryhonkitとかの仲間です。

GitLabで静的ページを作るのに、honkitを使っていたのですが、最近、Nanocを試していました。

nanoc.app

Nanocの利点

  • ビルドが速い(honkitと比較すると爆速といえる)
  • filterを書くのが簡単

デメリットは、honkitだと、色々プラグインが存在して楽なんですが、あんまりなさそう?というのと、僕がRubyに慣れてないくらいですね。

お題の件の通り、honkitだと、PlantUMLもプラグインで表示ができるのですが、nanoc ではできません。

Nanocのフィルターを書いてPlantUMLに対応する

ですが、Nanocは、自分で簡単にフィルターを書くことができます。

フィルターは、lib/nanoc/filters/ の下に、.rb なファイルを作り、identifier に名前をつけて、それを、Rules ファイルの中で使うだけです。

もともと、markdownをhtmlに変えるのは、kramdownというフィルターがありますが、このソースコードをコピーしてきて、下記のパッチを当てて、適当なファイル名で保存すればOKです。

@@ -2,10 +2,10 @@
 
 module Nanoc::Filters
   # @api private
-  class Kramdown < Nanoc::Filter
-    identifier :kramdown
+  class KramdownPlantUML < Nanoc::Filter
+    identifier :kramdownplantuml
 
-    requires 'kramdown'
+    requires 'kramdown-plantuml'
 
     # Runs the content through [Kramdown](https://kramdown.gettalong.org/).
     # Parameters passed to this filter will be passed on to Kramdown.

単に、名前を変えているのと、require しているものをkramdownからkramdown-plantumlに変えるだけですね。

そして、Rules のほうで、

  filter :kramdownplantuml, :input => "GFM"

のようにすれば、PlantUMLに対応することができます。

GraphViz::DBI で ER図を吐き出す

この記事は, Perl Advent Calendar 201920日目の記事です。 19日は、doikojiさんの、「WINI & cal: perlベースの新しい簡易マークアップ言語WINIで来年のカレンダーを作りましょう! 」でした。

この記事は、もともとWanoグループのAdvent Calendar に書くつもりでしたが、Perl の Advent Calendarに空きがあったので、 会社の方はRustの記事を載せました。よろしければ、そちらもどうぞ。

qiita.com

閑話休題

最近、DocBaseGrowiGitlabWiki (設定すれば)などPlantUMLでの表示をサポートするものが増えてきています。 自分の手でGraphVizのER図を書くのも可能ですが、正直、だいぶ面倒ですし、テーブル数が多いとやる気が出ません。

PerlGraphViz::DBIでさくっと書いてみましょう。

ですが、僕の担当している、とあるシステムはテーブル数が1000近くある関係で、はきだされたものをそのままPlantUMLに渡すと、長すぎて壊れてしまいました。 そらそうですね。

これは同種のテーブルが負荷対策の関係で分けたりしているのが原因で非常に多くなっちゃってるのですが、もし同様なケースでしたら、似たようなテーブルはグルーピングするなりの処理をしたり、 ER図をいくつかのグループに分割するなどしたほうが良いですね(例えば、カテゴリだけをグルーピングするとか、特定機能に関わるテーブルだけをグルーピングするなど)。

下記のようにグルーピングの定義をしてやると良いと思います。 この時に、似たようなテーブルがいくつかある場合は、1つのテーブルのみをグループの中に入れてやれば良いでしょう。

tie my %GROUP_TABLES, 'Tie::IxHash';
%GROUP_TABLES = (
    # グルーピング => 表示したいテーブルの正規表現を書く(※正規表現は、`schema`.`table_name` にマッチするようにする)
    'グループ1'       => qr{},
    'グループ2'       => qr{},
  );

出力時に下記のようにすれば、各グループ毎にER図を出力できます。

foreach my $group (keys %GROUP_TABLES) {
    my $filter = $GROUP_TABLES{$group};
    my $g = __PACKAGE__->new($dbh);
    $g->{tables} = [ grep {$_ =~ $filter} $g->get_dbh->tables];
    my $txt = $g->graph_tables->as_text;

 # これは、表示を簡略化するためなので、グルーピングとは関係ないです。 
   # `schema`.`table_name` => table_name だけにしています
    $txt =~ s{`$SCHEMA`\.`(.+?)`}{$1}g;
    print "## $group\n\n";
    print "```plantuml\n\@startuml\n", $txt, "\n\@enduml\n```\n\n";
}

他にも、削除フラグとか更新日時とか、ほとんどのテーブルにあるけど、特に表示したくないものもあるでしょうから、

my %IGNORE_COLUMNS = (
  updated_at => 1,
  delete_flag => 1,
  created_at => 1,
);

のように定義しておいて、graph_tables をオーバーライドして、調整するとよいかもしれません(後のコードを参照)。

また、GraphViz::DBI の外部キーの実装は、下記のようになっています。

sub is_foreign_key {
    # if the field name is of the form "<table>_id" and
    # "<table>" is an actual table in the database, treat
    # this as a foreign key.
    # This is my convention; override it to suit your needs.

    my ($self, $table, $field) = @_;
    return if $field =~ /$table[_-]id/i;
    return unless $field =~ /^(.*)[_-]id$/i;
    my $candidate = $1;
    return unless $self->is_table($candidate);
    return $candidate;
}

コメントを訳すと、

フィールド名が、"<table>_id"で、"<table>"が実際にDBに存在する
テーブルの場合、これを外部キーとします。
これは、自分の慣例なので、必要に応じてオーバーライドしてください。

とした、ざっくりな感じなので、コメントの通り継承してオーバーライドしました。

my %SPECIAL_FOREIGN_KEY = (
   '特殊な命名の外部キー1' => "`$SCHEMA`.`テーブル`", # 外部キーの参照しているテーブル
   '特殊な命名の外部キー2' => "`$SCHEMA`.`テーブル`",
   # ...
);

sub is_foreign_key {
    my ($self, $table, $field) = @_;

    my $candidate;
    if (not $candidate = $SPECIAL_FOREIGN_KEY{$field}) {
    # TABLE_NAME_ID は TABLE_NAME の外部キーとみなす
        return unless $field =~ /^(.*)[_-]id$/i;
        $candidate = "`$SCHEMA`.`$1`";
    }
    return unless $self->is_table($candidate);
    return $candidate;
}

僕も同じような慣例なので、例外的なものを追加するくらいでOKでした。

全体像は、こんな感じになります。

use strict;
use DBI;
use parent 'GraphViz::DBI';

my $SCHEMA = "スキーマ";
my $dbh = DBI->connect("dbi:mysql:$SCHEMA;host=DB_HOST", 'user', 'password');

use Tie::IxHash;

tie my %GROUP_TABLES, 'Tie::IxHash';
%GROUP_TABLES = (
    # グルーピング => 表示したいテーブルの正規表現を書く
    'グループ1'       => qr{},
    'グループ2'       => qr{},
  );

my %SPECIAL_FOREIGN_KEYS = (
   '特殊な命名の外部キー1' => "`$SCHEMA`.`テーブル`", # 外部キーの参照しているテーブル
   '特殊な命名の外部キー2' => "`$SCHEMA`.`テーブル`",
   # ...
);

my %IGNORE_COLUMNS = (
  created_at => 1,
  delete_flag => 1,
  updated_at => 1,
);


sub is_foreign_key {
    my ($self, $table, $field) = @_;

    my $candidate;
    if (not $candidate = $SPECIAL_FOREIGN_KEYS{$field}) {
    # TABLE_NAME_ID は TABLE_NAME の外部キーとみなす
        return unless $field =~ /^(.*)[_-]id$/i;
        $candidate = "`$SCHEMA`.`$1`";
    }
    return unless $self->is_table($candidate);
    return $candidate;
}
sub graph_tables {
    my $self = shift;

    my %table = map { $_ => 1 } $self->get_tables;

    for my $table ($self->get_tables) {
        my $sth = $self->get_dbh->prepare(
            "select * from $table where 1 = 0");
        $sth->execute;
        my @fields = @{ $sth->{NAME} };
        $sth->finish;

        my $label = "{$table|";

        for my $field (@fields) {
            next if $IGNORE_COLUMNS{$field};
            $label .= $field.'\l';
            if (my $dep = $self->is_foreign_key($table, $field)) {
                $self->{g}->add_edge({ from => $table, to => $dep });
            }
        }

        $self->{g}->add_node({ name => $table,
                               shape => 'record',
                               label => "$label}",
                           });

    }
    return $self->{g};
}

foreach my $group (keys %GROUP_TABLES) {
    my $filter = $GROUP_TABLES{$group};
    my $g = __PACKAGE__->new($dbh);
    $g->{tables} = [ grep {$_ =~ $filter} $g->get_dbh->tables];
    my $txt = $g->graph_tables->as_text;
    $txt =~ s{`$SCHEMA`\.`(.+?)`}{$1}g;
    print "## $group\n\n";
    print "```plantuml\n\@startuml\n", $txt, "\n\@enduml\n```\n\n";
    # Growiならこっち
    # print "\@startuml\n", $txt, "\n\@enduml\n\n";
}

こんな感じでやると、下記のようなER図になります(自分で作ってたサービスのDBです)。

f:id:ktat:20191220111301p:plain PlantUMLで表示

ちなみに、GraphViz::DBIは、13年前の更新が最後ですが、最新のPerlでも動くと思いますよ(5.8.9 でも、5.25.1でも動いたので)。

これを定期的に回して、Wikiのページも自動的に更新とかしてやれば、良いかもしれません。 ER図をDBの情報を読み取って吐き出すツールは普通にありますが、テーブル数が多いと、使い物になりませんので、こんな感じで特定部分をフィルタリングして出すのも割と有用だと思いますよ。

21日のPerl Advent Calendar 2019はMorichanさんによる「PerlだけでWebサイトを作る - Qiita」です。

tcat という行頭に日時を付加するコマンドを作った

cat のように標準入力かファイルの中身を出力するものですが、先頭行に日時がつきます。
https://github.com/ktat/tcat にあります。

なんで、こんなものが必要かというと、例えば、サーバの様子を top -b を記録して後からみたいなーとかいう時とかに...

% top -b >> top.log

みたいにしたすると、ファイルの中身はこんな感じ。

top - 00:44:33 up 16:59,  7 users,  load average: 1.76, 1.75, 1.62
Tasks: 345 total,   1 running, 344 sleeping,   0 stopped,   0 zombie
%Cpu(s): 30.3 us, 11.1 sy,  0.0 ni, 57.9 id,  0.4 wa,  0.0 hi,  0.2 si,  0.0 st
KiB Mem : 16312908 total,   409000 free,  9663728 used,  6240180 buff/cache
KiB Swap: 16657404 total, 16655344 free,     2060 used.  4595576 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 9618 ktat      20   0   42952   3800   3140 R  12.5  0.0   0:00.03 top
 5253 ktat      20   0 1745952 424904  69868 S   6.2  2.6   6:14.04 slack
 5306 ktat      20   0 1497416 376924 181836 S   6.2  2.3  17:12.10 slack
    1 root      20   0  185412   6012   3944 S   0.0  0.0   0:04.64 systemd
    2 root      20   0       0      0      0 S   0.0  0.0   0:00.04 kthreadd

後から見ようとした時に、どの時間のものだったのか調べようとすると....

top - 00:44:33 up 16:59,  7 users,  load average: 1.76, 1.75, 1.62

ここにあるけど、とっても使いにくいし、日またいだら、どうするんだという感じなので...

% top -b | tcat >> top.log

とすることで、

2017-01-25 00:48:25: top - 00:48:25 up 17:03,  7 users,  load average: 1.29, 1.63, 1.62
2017-01-25 00:48:25: Tasks: 341 total,   1 running, 340 sleeping,   0 stopped,   0 zombie
2017-01-25 00:48:25: %Cpu(s): 30.1 us, 11.0 sy,  0.0 ni, 58.3 id,  0.4 wa,  0.0 hi,  0.2 si,  0.0 st
2017-01-25 00:48:25: KiB Mem : 16312908 total,   582792 free,  9512076 used,  6218040 buff/cache
2017-01-25 00:48:25: KiB Swap: 16657404 total, 16655352 free,     2052 used.  4772668 avail Mem 
2017-01-25 00:48:25: 
2017-01-25 00:48:25:   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
2017-01-25 00:48:25:  9850 ktat      20   0   42952   3732   3076 R  12.5  0.0   0:00.03 top
2017-01-25 00:48:25:     1 root      20   0  185412   6012   3944 S   0.0  0.0   0:04.64 systemd
2017-01-25 00:48:25:     2 root      20   0       0      0      0 S   0.0  0.0   0:00.04 kthreadd
2017-01-25 00:48:25:     3 root      20   0       0      0      0 S   0.0  0.0   0:08.62 ksoftirqd/0
2017-01-25 00:48:25:     5 root       0 -20       0      0      0 S   0.0  0.0   0:00.00 kworker/0:0H

のように記録される(日付のフォーマットは自由に変えられます)ので、

% grep '2017-01-25 00:48' top.log

のようにして、検索しやすくなります。

で、作ってから気づいたのですが、まったく同じ名前の同様のコマンドが C で書かれていました。
https://github.com/marcomorain/tcat

orz

とりあえず、違いを出すために、cat のオプションを全て実装してみました("-n" 以外使ったことないけど)。
"-v", "-E", "-T" あたりの実装は、cat のソースコードからロジックをそのまま移した感じになります。