Ruby で作る Coding Agent

Coding Agent が簡単に作れるよってよく聞くので、理解のために自分でも実装してみました。

なんかググってもNode.jsとかPythonとかGoとかで書かれているのばかりなのでせっかくだし自分が大好きなRubyで!

リポジトリはここ: https://github.com/yoshiori/r2d2/

ModelとかはとりあえずGeminiつかってみる感じで、各セクションのその時のコミット貼って置きます。

最初のコード

https://github.com/yoshiori/r2d2/commit/7c6145961bd6b71696c4630f55c86a6f25ec3556

プロンプトのテキストとかは長いので雑にコードを書くと最初はこれだけ。

  1. ユーザーの入力受け取る
  2. Gemini に問い合わせ
  3. レスポンスを表示

を loop で無限にループするだけです。これがベースになるコード。

  def self.start(_args)
    puts "R2d2 is starting..."
    loop do
      input = gets.chomp
      response = gemini.stream_generate_content({
        contents: { role: "user", parts: { text: input } }
      })
      response.each do |message|
        message["candidates"].each do |candidate|
          candidate.dig("content", "parts").each do |part|
            puts "R2d2: #{part["text"]}"
          end
        end
      end
    end
  end

履歴を持つようにする

https://github.com/yoshiori/r2d2/commit/fa2ef21a3b6b8464cdf18e75e9e193c5416693c3

さっきの実装だけでも最低限動くんだけど会話履歴持ってないので毎回ボケ老人と会話しているみたいになる。 なので会話履歴持つようにする。 会話履歴と言っても簡単で話した内容を配列で持っておいて送るだけ。なので @history っていうインスタンス変数保持しておいてそこに追加して今までのtextのかわりにそれを送るだけ。受け取ったらそれも追加しておく。

role は user と model 交互にあることが期待されているのでそこだけ注意

  def chat(text)
    messages = []
    @history << { role: "user", parts: { text: text } }
    response = gemini.generate_content({
        contents: @history,
        system_instruction: { parts: { text: PROMPT } }
    })
    p response
    response["candidates"].each do |candidate|
      candidate.dig("content", "parts").each do |part|
        messages << part["text"]
        @history << { role: "model", parts: { text: part["text"] } }
      end
    end
    messages
  end

という感じ。

ツールを作る

https://github.com/yoshiori/r2d2/commit/dd32bd7009a27afabb7468d64ced40cc912e0876

ここまでだとチャットを実現しただけなので、このへんからコーディングエージェントっぽいことをやっていく。と言ってもあとはエージェントにツールをどんどん追加するだけ。

例えばファイルを読むツールは下記のように定義する

    @tools = {
      function_declarations: [
        {
          name: "read_file",
          description: "Read the contents of a file at the given path. " \
               "Use this to understand code, gather context, or inspect files " \
               "before answering questions or making decisions.",
          parameters: {
            type: "object",
            properties: {
              path: {
                type: "string",
                description: "The relative file path from the current working directory"
              }
            },
            required: ["path"]
          }
        }
      ]
    }

そう、MCP の定義と一緒。まぁ、そりゃそうか

このツールをリクエストに含めるようにします。

    response = gemini.generate_content({
        contents: contents,
        contents: @history,
        tools: @tools,
        system_instruction: { parts: { text: PROMPT } }
    })

そうするとレスポンスにfunctionCallというのが含まれるようになります。そこにどのツール使いたくて引数は何なのかとかが入っています。 なので、それを使ってて実行するようにする。たとえばさっきの read_file だと読みたいファイルのパスが入ってくるのでその中身をまたリクエストとして投げてあげれば良い。 これらもさっきの @history に全部入れて投げる。

 @history << { role: "model", parts: parts }

  function_response = []
  parts.each do |part|
    if part["functionCall"]
      name = part["functionCall"]["name"]
      args = part["functionCall"]["args"]

      result = ReadFile.new.execute(args["path"])
      function_response << { functionResponse: { name: name, response: { result: result } } }
    else
      messages << part["text"]
    end
  end

  unless function_response.empty?
    @history << { role: "user", parts: function_response }
    messages.concat(generate)
  end

ちなみに ReadFile の初期実装はこんなに簡単

class ReadFile

  def execute(file_path)
    File.read(file_path)
  end
end

一旦リファクタリング

https://github.com/yoshiori/r2d2/commit/61ab9c853a8afd37eb69e3dc353c9618e8acd53c

これでhistoryとtoolsという最低限のやりたいことは揃ったのでリファクタリング。今回はこのへんは趣旨に反するので省略する。

ツールを充実させる

https://github.com/yoshiori/r2d2/commit/7402cb84ef0f3c5bb8dc1f609d757277e5ffd6dd

こうやって1個づつツールを作っていくのもいいんだけど、もっと雑にコマンド実行出来るようにしておけば柔軟性が高いのでそれを作る。例えばファイル一覧ほしいときとかもAIが勝手に ls 叩いてくれれば良いので。

require "open3"

class ExecCommand
  def self.name
    "exec_command"
  end
  def self.description
    "Execute a shell command and return its output. " \
    "Supports any UNIX command (ls, grep, find, cat, curl, etc.)."
  end
  def self.parameters
    {
      type: "object",
      properties: {
        command: {
          type: "string",
          description: "The command to execute"
        },
        args: {
          type: "array",
          description: "Array of arguments for the command",
          items: {
            type: "string"
          }
        }
      },
      required: ["command"]
    }
  end
  def self.definition
    { name: name, description: description, parameters: parameters }
  end
  def execute(command:, args: [])
    output, status = Open3.capture2e(command, *args)
    output
  end
end

こんな感じのクラス作って追加するだけ。簡単なファイル書き込みもコマンドでやってくれるので最低限のコーディングエージェントとしての動きは出来た。

ファイル書き込みTool追加

https://github.com/yoshiori/r2d2/commit/2b3c45588c6de726454a447e1fe20ab0517b1246

上記のコマンド実行作成したらほぼ動いたんだけどファイルに書き込むのだけはたまにリダイレクトでは失敗することが合ったのでファイルに書き込むtoolを追加する。

class WriteFile
  def self.name
    "write_file"
  end

  def self.description
    "Write content to a file. If the file already exists, it will be overwritten."
  end

  def self.parameters
    {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "The relative file path from the current working directory"
        },
        content: {
          type: "string",
          description: "The content to write to the file"
        }
      },
      required: %w[path content]
    }
  end

  def self.definition
    { name: name, description: description, parameters: parameters }
  end

  def execute(path:, content:)
    FileUtils.mkdir_p(File.dirname(path))
    File.write(path, content)
    "Successfully wrote to #{path}"
  end
end

これで終わり。

テストの実装くらいは出来る

これくらい実装してあればある程度使えます。 テストを書いていなかったのでテスト書かせましょう。

実際に自分でテスト書かせてるデモはこちら ↓


www.youtube.com

Historyの圧縮

https://github.com/yoshiori/r2d2/commit/5365c589b4e84e1d541b5e46d378f26bbcafdcd5

結構対話を繰り返していると token が足りなくなることがあります。適度なタイミングで圧縮するとよいのでそれを実装します。 Responceには送信した内容(contents + system_instruction)のtoken数、つまりプロンプトと今までの会話のtoken数が入っています。なのでそれをみて閾値を超えそうだったら圧縮しましょう。

prompt_tokens = response.dig("usageMetadata", "promptTokenCount") || 0
compress_history! if prompt_tokens > TOKEN_LIMIT

感覚的には古い会話は圧縮したいので、直近の会話はそのままとっておきます。とりあえず僕は10個くらいはとっておくようにしています。 ここで注意点なのですが、無差別に10件で切っちゃうとツールを使った履歴が途中で切れておかしなことになってしまいます。 なので functionCall/functionResponse ペアが壊れない位置で分割します。そして最後がmodelになるようにします。

 def find_safe_split_index
    from = @history.size - RECENT_KEEP_COUNT

    index = @history[0...from].rindex do |msg|
      msg[:role] == "model" && msg[:parts].none? { |p| p["functionCall"] }
    end

    index ? index + 1 : 0
  end

これで分割位置は決まったので、これより前を圧縮します。それようのプロンプト作って投げればいいだけです。

  def compress_history!
    split_at = find_safe_split_index
    return if split_at <= 0

    old_history = @history[0...split_at]
    recent_history = @history[split_at..]

    summary_response = gemini.generate_content({
        contents: old_history + [{ role: "user",parts: { text: SUMMARIZE_PROMPT } }]
    })

    summary_text = summary_response.dig("candidates", 0, "content", "parts", 0, "text")

    @history = [
      { role: "user", parts: { text: "Summary of the conversation so far:\n#{summary_text}" } },
      { role: "model", parts: [{ text: "Understood. Let's continue." }] }
    ] + recent_history

    puts Reinbow("History compressed: #{old_history.size} messages summarized").faint
  end

終わり

自分で実装してみると思いの外簡単に実装できましたね。僕自信は色々な発見があって面白かったです。 あとは汎用的なAgentにしてもいいし、なにかに特化したエージェントにしたりしても面白いですね。Web検索するtoolつくったりも残っているので引き続き盆栽的に楽しもうと思います。

買収されてからの近況と今の仕事面白よって話と採用ネジ込みたい話

こんにちはヨシオリです。 めちゃくちゃ久しぶりなんですが、最近の会社の状況がかなり面白くなってきているのと、エンジニアの採用枠がちょうど「1個」空いているので、それに応募してほしくて久しぶりに書いています。

最初に、今やってる仕事を雑に説明

もともと Launchable という会社を立ち上げて作っていたプロダクトが CloudBees に買収されて、今は CloudBees Smart Tests という名前になっています。

やっていることを一言で言うと、「AIによるソフトウェアテストの最適化」です。 CIとかで実行された膨大なテスト結果を学習して、コードを書いたときに「今回はこのテストとこのテストだけ実行しておけばOKですよ」というのを賢く教えてくれます。 これを使えば、PRのときは最小限のテストで爆速で回して、デプロイ直前だけフルで実行する、みたいな運用がスマートにできる。

あとは、テスト結果のデータを溜めているので、「どのテストが不安定(Flaky)か」とか、「複数のテストが落ちてるけど、原因はこれ一個(ミドルウェアの接続エラーとか)だよ」といった情報も出せます。 テスト結果というデータを元にAIなどを駆使して開発体験を良くしようぜ、っていうのが今の僕らの主戦場です。

これはホント褒めてほしくて書いちゃうんだけど、僕らがやってるサービスがInfoWorldのTesting部門でアワードとったのよ!!! 褒めて欲しいでみんなも広めてくれ!!!! www.infoworld.com/article/4104...

Yoshiori (@yoshiori.bsky.social) 2025-12-16T07:19:34.122Z

bsky.app

買収されて1年半、今の立ち位置

会社が買収されてから1年半くらい経ちました。 個人的な感想ですが、CloudBeesは今めちゃくちゃAIに力を入れたがっていて、そのために僕らを買ったはずなので、社内でもかなり重要なチームとして扱われています。 全社ミーティングでもよく名前が出るし、セールスチームの最重要プロダクトの一つになっていたりもする。そのへんも含めて、なぜ「今」が面白いのかを書いてみます。

世界規模の検証データにビビる

Launchableの時みたいな「0→1」のフェーズも刺激的でしたが、ある程度の規模の会社で重要プロダクトをゴリゴリ開発するのもやっぱり面白いです。 。特にCloudBeesのセールスの人たちはエンタープライズに強いので、顧客が誰もが知る「世界的企業」だったりします。

例えばある超大企業は導入検討のために、僕らでもやらないようなコストをかけて徹底的に検証してくれたんですね。 結果、「検証の結果、とても良い性能が出ているので導入したい」と言ってもらえた。 それ自体も嬉しいんだけど、それ以上に「うわ、俺らの作ったやつ、こんな大規模な環境でこんな成果出るんだ……!」って驚いたりしました。

(本当は僕らがそれらの大規模調査データを作るべきなんだろうけど小さいチームだしそこまでお金ないので大企業の検証チームに徹底的に検証してもらえて嬉しかった)

そういった世界的企業と仕事をするのはデータの規模も桁違いだし楽しいです。

生成AIブームと「AI予算」のリアルな話

ご存知の通り最近の Agentic AI coding の流れは本当に凄くて、世の中にコードが生成される速度が爆上がりしています。 そうなると当然、動かすべきテストの量も爆発的に増える。結果として、僕たちのサービスの必要性も勝手に上がっていくという状況です。

あと、これはかなりぶっちゃけた話ですが、今ってどこの会社も「AI活用予算」が潤沢なんですよね。 株主や投資家への手前もあってか、普通のSaaSよりもお金を払ってもらいやすいという現実がある。

今、一番熱い領域で、ちゃんとお金を稼ぎながら開発できるのは、やっぱり楽しいです。

「三単現=麻雀の役?」レベルからの英語生活

5年前の僕は「三単現? 麻雀の役だっけ?」くらいの英語力でしたが、気がつけば毎日英語で仕事をして、レポートラインも上下ともに英語です。 チームにはインド、スリランカ、オーストラリア……といろんな国のメンバーがいますが、ネイティブじゃない人も多いので、お互い「優しい気持ち」でコミュニケーションしています。この前、忘年会的なのやったけど英語で飲み会するのにも気がつけば慣れていました。

こういう話をするともう英語できるから翻訳とか使わないでしょ?とか思われるかも知れないけど、文章書いたりとか読んだりとかはガンガンAIをつかってます。

僕くらいの英語力でも、他のチームのリーダー陣や人事と渡り合って仕事は回せる。これは、これから海外に挑戦したい人にとっても、だいぶ心強い話なんじゃないかと思います。(もちろん、まだまだなんだけど)

毎日英語に揉まれるなかで、少しずつ「できること」が増えていく感覚も純粋に楽しいですよ。

というわけで、一緒に働きませんか?

これ、USの会社あるあるなんですけど、採用は「スロット(枠)」があるかどうかがすべてなんですよね。どんなに優秀な人が来ても、枠がなければ採用できない。

で、今うちのチームには「1個」だけスロットがあります。 本当のことを言うと、この枠、表向きは「インドでの採用」として募集されているんです。 募集要項(Sr. Software Engineer AI - Full Stack)

でも、ボスと「日本でいい人見つけたら、強引にこの枠を日本側にネジ込もうぜ」という話をしています。

興味がある人、とりあえず最近の現場の話を聞いてみたいという人は、気軽に連絡ください! 連絡先知らない人は yoshiori@gmail.com にメールくれればOKです。 (※Xはもう見てないので、XのDM以外でお願いします!)

最近声日記ってやつをやっています

最近、声日記ってやつをはじめてます!

listen.style

もともとは id:ninjinkun と話してるときに「最近、声日記やってるんですよー」っていっていて、なんかブログより手軽に出来て日々のちょっとしたこととか話せていいですよ。って言われたのではじめてみたかんじです。

で、「とりあえずページネーションが出るようになるくらいまで投稿溜まったらブログにも書こう」って思ってたのですが、今日とうとうページネーションが出現したので宣伝してみます。

僕はどんなスタイルでやってるかっていうと

  • 使うサービスはにんじんくんに教えてもらったまま LISTEN を使う
  • ブラウザからそのまま撮って出し(編集とかしない)
  • 話す時間は3分目安(大幅に超えることも)

ってやってるんですね。で、これはなんとなくそうやってはじめたんだけどすごく良いなぁと思っていてアウトプットのための敷居が僕の中でだいぶ下がったんですよね。 なんかちょっとブログに書こうかなとか思っててもブログに書こうとするとそれなりに時間かかるので躊躇しちゃうんですよね。 たとえばこれなんかも書き始めてからもう10分くらい経ってる。こんな雑な文章でも10分くらい経ってる。

でも3分の声日記だとタイトルとか概要とか書いても5分で終わる。リモートで仕事してるから目の前にマイクあるのでホントに LISTENのページ開いてすぐに録音初めてタイトルと概要書いて終わり。 最初はポッドキャストとか声日記的なのはハードル高いと思ってたんだけど逆だったんですよね。サクッと公開できる。(これはLISTENっていうサービスが良いというのもあると思います)

5分で終わるってなると、仕事一旦終わらして晩御飯作る前にとかにサクッと公開出来るんですよね。しかもなんかタイトルに「日記」って付けたので雑に日記的なことを言えばいいだけっていう。そんなことをやってアウトプットする筋肉を取り戻そうとやっています。そんなアウトプット筋の話はこちら

listen.style

せっかくページネーションでるまでやってみたのでしばらく続けようと思います。楽だしね!!!

真・WEB+DB PRESS総集編的なやつにエッセイを寄稿させてもらった!

みんな大好き「WEB+DB PRESS」が隔月刊誌としては休刊するということで、真の総集編的な全部入り総集編が出ました。

DVDとダウンロードコード付き!!! 全記事PDF+全文検索っていう意味がわからないほど豪華なのに3000円っていう実質無料な感じなのでみんな買うと良いと思います。

で、僕もそこに寄稿させてもらいました。で、そのときの依頼メールに「エッセイを執筆してくれ」って書いてあって興奮してしまいました。

 

だって、エッセイですよ。エッセイ。好きなんですよ、エッセイ。

で、興奮してエッセイについての思いを導入に書き始めたら全然ページに収まらなくなってしまって結局削りました。

今読むと「これ、完全に自己満だから消しておいてよかったな」と思うんですがせっかくなのでここに載せて供養しようと思います。

エッセイを書くということの意味

この原稿は「Web開発者として生きるあなたへという題材でエッセイを書いてください」という依頼から書くことになった。エッセイである。僕の中でエッセイというのは小説家が自分の言葉で日常に感じたことやちょっとしたことを書くものの印象が強い。普段物語を読んでいるときには見えなかった作者の個人的な側面が見えてちょっとしたファン心理的にも楽しい読み物だった。特に若い頃は椎名誠のエッセイが大好きで沢山読んでいた記憶がある。その出会いを少しだけ語ると中学生のころに遡る。当時椎名誠は『岳物語』が教科書にのっていてお堅い人だと思っていた。まだエッセイとかあんまり読んだことがなかったある日、本屋さんで『かつをぶしの時代なのだ』というゆるーいタイトルの本を手にとったら作者がそのお堅い人だと思っていた椎名誠なので興味を持った。そこから椎名誠のエッセイにハマって他の人のも読んだりしたが僕の中ではエッセイといえば椎名誠なのである。そしてエッセイというのはそういう著者が自分の言葉で書くものなのだという概念から逃げられなかった。逃げられなかったので今、執筆依頼時に『本文は原則「ですます調」を想定していますが、「である調」のほうがよければその形でも大丈夫です。』と言われたのにガン無視して口語体で書いている。
人生の中でまさか自分が「エッセイを書いてくれ」なんて依頼を受けれるとは思っていなかったのでせっかくのチャンスだし楽しんで僕の中にある「エッセイっぽい文体」で書かせてもらおうと思っている。そう、そしてこうやって前置きをグダグダ書いてなかなか本題に入らないのもエッセイっぽいと思ってしまうのだ。

みなさん買いましょう!!

隔月刊誌としては休刊ってことなんだけど、なんか売上良ければちょいちょい出るんじゃないかなぁとか勝手に期待してるんですよね!!!!なんでみんな買いましょう!!!!

そして記事をChatGPTとかに学ばせて質問すれば最高な体験が出来るかも!!!(適当)

 

WEB+DB PRESS総集編[Vol.1~136] (WEB+DB PRESS plusシリーズ)

オンラインミーティングが始まったら自動で点灯するオンエアーネオンライト作った

家で仕事するようになったときからずっとオンラインミーティング始まったら自動で点灯するネオンサインみたいなのあったら良いなぁと思ってた。 まぁぼんやり思ってるだけだったんだけど、ちょっとやる気が出たのでガッと組んでみた(確定申告の書類集めとかしてるとそうなるよねー)

雑要件定義

  • 絶対に自動でON/OFF
  • 取り回しのためPCとは直接有線で繋げない
  • ミーティングはZoomだったりGoogle Meetだったりあるのである程度汎用的な方法

ミーティングの自動判別

たまにネットで出てくる記事でよく使われているZoomアプリのプロセス監視するのはGoogle Meetに対応できないので無し。カレンダー監視してミーティングの時間とか考えたけど、精度低いし突発の仕様相談とかに対応できないので無し。

結局仕事のミーティングはすべてカメラONにして行っているのでカメラの監視をすることにした。たまにゲームでDiscordつかって音声だけのときもあるけど、奥さん的要件としてはカメラに映り込みたくないというのが大きのでカメラの監視をすることにした。

で、カメラがつながっているかどうかではなく、実際に起動しているかどうかの監視はlsofコマンドで行える。

# lsof /dev/video0 
COMMAND    PID     USER FD   TYPE DEVICE SIZE/OFF NODE NAME
pipewire  2019 yoshiori 52u   CHR   81,0      0t0 2289 /dev/video0
wireplumb 2020 yoshiori 47u   CHR   81,0      0t0 2289 /dev/video0

linuxだとこんな感じ。(pipewireとかwireplumbはWayland下でオーディオデバイス扱うやつなので無視して大丈夫) 多分Macでも似たようなもんだと思う。

あとはこれをループで監視すればON/OFFが判別できる。

ライトのオンオフをリモートで操作

PCで会議のイベントは取れるようになったのであとはプログラムからネオンライトを操作できれば良い。昔ならIFTTTとか使おうとか考えたんだけど、今やIFTTTのwebhookは高級有料プランになってしまったので無し。スマートデバイス、PCから扱うのはじつは面倒くさいんだよなぁとか思いつつ、なんか他に無いかなぁと思ってたらSwitchBotはAPIを公開してた!マジでAPI公開してる企業は政府から補助出てほしい!!ありがとうSwitchBot!!

で、Rubyのgemもあったのでめっちゃあっさりできた。

    class Light
      def client
        @client ||= Switchbot::Client.new(ENV["TOKEN"], ENV["SECRET"])
      end

      def on
        client.device(ENV["DEVICE_ID"]).on
      end

      def off
        client.device(ENV["DEVICE_ID"]).off
      end
    end

これで道具は揃ったのであとは作るだけ。

材料

ON AIR ネオンライト

物理スイッチなので本体のスイッチはいれっぱなしにしてスマートプラグでON/OFF操作できる。あと電池も対応しているけど有線でも使えて接続口がUSB-Cなのも地味に嬉しい。

スマートプラグ

商品説明にはAPIについて全く触れられていない。なぜなのか!?(マニアックだから?)

【Option】薄型 USB-C ケーブル

USB-C のコネクタ側が薄型のL字なので接続時に綺麗に見える。初代Apple Pencilの突き刺さりが美しいと思った人はいらないと思う。

プログラム

github.com

ということで前回かいた「超楽にRubyで雑に書いたスクリプトをsystemdで管理したい! - 宇宙行きたい」はこれを動かすためでした。

まとめ

SwitchBotが実はAPI公開しててさくっと色々なハックが出来るのはもっともっと知られてほしい!!!!最高便利!ありがとう!!!

超楽にRubyで雑に書いたスクリプトをsystemdで管理したい!

ちょっとした雑なスクリプト書いてそれを常に起動しておきたいときないですか?僕はあります。

しかもめんどくさがり屋なのでghq管理化のディレクトリでgemに頼って雑に書いたスクリプトがそのまま動いてほしいんです。

systemd使えば出来るんだろうなぁと思いつつ「色々面倒くさいんだろうな」と思って手を出していなかったんだけどやってみたら拍子抜けするほど簡単だったので共有です。

雑要件定義

  • rubyはrbenvで管理してるんでそれそのまま使ってほしい
  • 実行ファイルをgit&ghq管理化のディレクトリでそのまま使いたい
    • 他の場所にインストールとかコード管理ダルい
  • bundle exec 的なのもやって依存ライブラリもうまいことやってほしい

やること

systemdをユーザーレベルで使うときは ~/.config/systemd/user/にファイルを置けば良い。ディレクトリ無かったら作る

mkdir -p ~/.config/systemd/user/

そこにsystemd ユニットファイルを作る。例えばhogeっていうプロジェクトならhoge.serviceという名前のファイルを作る。中身はこんな感じ。

[Unit]
Description=hoge

[Service]
Type=simple
Restart=always
Environment="PATH=%h/.rbenv/shims:/usr/local/bin:/usr/bin:/usr/local/sbin:"
ExecStart=%h/src/github.com/yoshiori/hoge/bin/run
WorkingDirectory=%h/src/github.com/yoshiori/hoge

[Install]
WantedBy=default.target
  • Environment
    • ここにPATH設定をかいてrbenvをにもパスを通す。
  • ExecStart
    • 実行するコマンド
  • WorkingDirectory

%hsystemdが定義しいる置き換え文字でユーザーのホームディレクトリになる

で、実行するファイルの中でbundler/setup呼んであげれば良い。

#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/setup"

デバッグ

とりあえず実行

systemctl --user start サービス名.service

動かなかったらログ見る

 journalctl --user -eu サービス名.service

修正したらリロード

 systemctl --user daemon-reload     

あとは正常に動くようになるまで微調整

登録

ここまできたらいつものsystemdと使い方変わらない

登録

systemctl --user enable サービス名.service

登録されていることを確認

systemctl --user list-dependencies 

おわり

まとめ

簡単だからsystemdをユーザーレベルで使えばよい。そうすればログイン時に起動するデーモン作れる。

JavaでUUID.compareToの挙動が思ってたのと違った

UUIDはv6,v7,v8とかで時間ベースのソートが出来るようになった。べんりー

なのでUUIDの上下関係が大事になったんだけどJavaのUUID.compareToはそこまで考慮されていなさそうだった。

たとえば2つのUUIDを用意してみる。

  • 00000000-0000-7dda-8cc3-13f289aa2814
    • 1970/01/01とかでつくったUUID
  • e677d1fe-e676-7224-be39-f88d1cd436d8
    • 9999/12/31とかでつくったUUID
    • こっちのほうが大きくなってほしい

まあ、見た目通り文字列で比較すればe677d1fe-e676-7224-be39-f88d1cd436d8のほうが大きい。 ちなみに postgres で比較するとそのとおりの挙動になる

neko=>  select ('00000000-0000-7dda-8cc3-13f289aa2814')::uuid < ('e677d1fe-e676-7224-be39-f88d1cd436d8')::uuid;
 ?column?
----------
 t
(1 row)

でも、Java(21)でUUIDオブジェクトとして比較すると逆になる

# jshell                                                                                                     2023ms  20240222154921秒
|  Welcome to JShell -- Version 21.0.2
|  For an introduction type: /help intro

jshell> UUID max = UUID.fromString("e677d1fe-e676-7224-be39-f88d1cd436d8");
max ==> e677d1fe-e676-7224-be39-f88d1cd436d8

jshell> UUID min = UUID.fromString("00000000-0000-7dda-8cc3-13f289aa2814");
min ==> 00000000-0000-7dda-8cc3-13f289aa2814

jshell> max.compareTo(min)
$3 ==> -1

ちなみに文字列にして比較すると(当たり前だけど)見た目通りの挙動になる。

jshell> max.toString().compareTo(min.toString())
$4 ==> 53

なぜかと言うとOpenJDKの実装では前半と後半にわけて(符号付きの)longとして保持している。

    public UUID(long mostSigBits, long leastSigBits) {
        this.mostSigBits = mostSigBits;
        this.leastSigBits = leastSigBits;
    }

https://github.com/openjdk/jdk/blob/d60331a21c30271340f7d6d58f3122f0e6431a04/src/java.base/share/classes/java/util/UUID.java#L140-L143

で、compareToも実装されていていて、最初に前半のlongで比較している

    @Override
    public int compareTo(UUID val) {
        // The ordering is intentionally set up so that the UUIDs
        // can simply be numerically compared as two numbers
        int mostSigBits = Long.compare(this.mostSigBits, val.mostSigBits);
        return mostSigBits != 0 ? mostSigBits : Long.compare(this.leastSigBits, val.leastSigBits);
    }

https://github.com/openjdk/jdk/blob/d60331a21c30271340f7d6d58f3122f0e6431a04/src/java.base/share/classes/java/util/UUID.java#L557-L562

で、このmostSigBitsの値が一定の大きさを超えたUUIDではマイナスになる(まぁ、それはそう)。 例えば上記のものは内部的には下記のような感じ

max = {UUID@3077} "e677d1fe-e676-7224-be39-f88d1cd436d8"
 mostSigBits = -1839771030039137756
 leastSigBits = -4739483847872989480

ちなみに変換ロジックはこれ

    public static UUID fromString(String name) {
        if (name.length() == 36) {
            char ch1 = name.charAt(8);
            char ch2 = name.charAt(13);
            char ch3 = name.charAt(18);
            char ch4 = name.charAt(23);
            if (ch1 == '-' && ch2 == '-' && ch3 == '-' && ch4 == '-') {
                long msb1 = parse4Nibbles(name, 0);
                long msb2 = parse4Nibbles(name, 4);
                long msb3 = parse4Nibbles(name, 9);
                long msb4 = parse4Nibbles(name, 14);
                long lsb1 = parse4Nibbles(name, 19);
                long lsb2 = parse4Nibbles(name, 24);
                long lsb3 = parse4Nibbles(name, 28);
                long lsb4 = parse4Nibbles(name, 32);
                if ((msb1 | msb2 | msb3 | msb4 | lsb1 | lsb2 | lsb3 | lsb4) >= 0) {
                    return new UUID(
                            msb1 << 48 | msb2 << 32 | msb3 << 16 | msb4,
                            lsb1 << 48 | lsb2 << 32 | lsb3 << 16 | lsb4);
                }
            }
        }
        return fromString1(name);
    }

https://github.com/openjdk/jdk/blob/d60331a21c30271340f7d6d58f3122f0e6431a04/src/java.base/share/classes/java/util/UUID.java#L242C1-L265C6

まぁ、Java のUUIDは4くらいまでしか対象にしていない気がするのでしょうがないような気がするし、これが仕様なのかバグなのかは人によって捉え方が違うと思う。

ただ、UUIDv7とかのロジックで生成したUUIDをJava上でオブジェクトとして比較すると順番が思ったのと違うことになるというお話でした。 僕はとりあえずtoString()して比較するようにします。