こんにちは!MaTTaと申します。
これまでWEBアプリ開発の記事をいくつか書いて来ましたが、今回は少し趣を変えてオリジナルのgemを開発したのでその経験をまとめてみます。
初学者につき、誤りを含む場合があります。
また、当Gemは各自のOpenAI API KEYの使用を前提にしておりその分の費用が発生します。
開発経緯
プログラミングスクールの先輩が「アプリもいいけどGem作りもいい経験になるよ」と仰っていたのが最初のキッカケでした。「gemを作る」なんて今まで思いつきもしなかったのですが、よくよく考えるとGemって何なのかしっかり理解していないようにも思い、これもいい機会だなと。
刺激をくれた先輩の記事はこちらです。手順についてはそのまま参考にさせて頂きました。
🐾動物言語🐾(pad_character)を『gem』で作ってみた!🐈
なにを作ったか
普段のWEBアプリ開発でOpenAI APIを使うシーンがかなり多く、毎回同じコードを使い回しコピペしているのでこの部分をgem化してみました。
プロンプトを投げたらOpenAI APIからその回答が返ってくるシンプルなものです。
こちらに公開しています
使い方はGithubに記しています
gemの正体は何なのか
Railsアプリ開発をする側としては「なんか色々助けてくれるやつ」という印象のgemですが、簡単に言えばクラスやモジュールを切り出したものであり、Ruby言語で書かれていることに違いはありません。加えて、gem同士の依存関係などの情報も記録されています。
今回作ったgem程度のシンプルな内容しかも個人開発であればアプリ内に直接記述すれば済む話ですが、ロジックが複雑なモジュールだとそうも言ってられないのでgemとしてパッケージ化し、楽に使いまわせるようにしています。
gemの名前を考える
gemの名称は一意でないといけません。Gemfileにgem名を書くわけなので当然です。gemの配布を取りまとめているRubyGem.orgにアクセスし、検索機能を使いながら、唯一無二のgem名を考えます。gem名は通常クラス名にも使うため、後から修正するのは非常に面倒くさいです。先にして済ませるのが吉。
gemのテンプレートを作成する
一応、私の開発環境
ruby -v
# ruby 3.1.6p260 (2024-05-29 revision a777087be6) [arm64-darwin23]
bundle -v
# Bundler version 2.3.27
なにから作ればいいのか途方に暮れそうですが、実はコマンド一つでテンプレートが作成される新設設計がRubyには施されています。
下記のコマンドを打ちます。gem名はご自身で命名したものに変えてください
bundle gem your_gem_name
すると、いくつか質問がくるので順次回答していきます。回答はお好みで。
テンプレート作成時に投げかけられる質問
testフレームワークを選べという質問。rspecと入力してEnter
Creating gem 'openai_single_chat'...
Do you want to generate tests with your gem?
Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.test`.
Enter a test framework. rspec/minitest/test-unit/(none): rspec
CIサービスを選べという質問。githubと入力してEnter
Do you want to set up continuous integration for your gem? Supported services:
* CircleCI: https://circleci.com/
* GitHub Actions: https://github.com/features/actions
* GitLab CI: https://docs.gitlab.com/ee/ci/
* Travis CI: https://travis-ci.org/
Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.ci`.
Enter a CI service. github/travis/gitlab/circle/(none): github
配布ライセンスをMITにするか?という質問。yesなのでyでEnter
Do you want to license your code permissively under the MIT license?
This means that any other developer or company will be legally allowed to use your code for free as long as they admit you created it. You can read more about the MIT license at https://choosealicense.com/licenses/mit. y/(n): y
オープンソースプロジェクトの行動規範を遵守するかという質問。yesなのでyでEnter
Codes of conduct can increase contributions to your project by contributors who prefer collaborative, safe spaces. You can read more about the code of conduct at contributor-covenant.org. Having a code of conduct means agreeing to the responsibility of enforcing it, so be sure that you are prepared to do that. Be sure that your email address is specified as a contact in the generated code of conduct so that people know who to contact in case of a violation. For suggestions about how to enforce codes of conduct, see https://bit.ly/coc-enforcement. y/(n):y
ソフトウェア開発プロジェクトにおける変更履歴(チェンジログ)を採用するかという質問。yesなのでyでEnter(...したけど未対応)
A changelog is a file which contains a curated, chronologically ordered list of notable changes for each version of a project. To make it easier for users and contributors to see precisely what notable changes have been made between each release (or version) of the project. Whether consumers or developers, the end users of software are human beings who care about what's in the software. When the software changes, people want to know why and how. see https://keepachangelog.com y/(n): y
Ruby gemプロジェクトにコードリンターとフォーマッターを追加するかどうか。rubocopを入力してEnter
Do you want to add a code linter and formatter to your gem? Supported Linters:
* RuboCop: https://rubocop.org
* Standard: https://github.com/testdouble/standard
Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.linter`.
Enter a linter. rubocop/standard/(none): rubocop
対応していくと、下記の要領でファイルが自動作成されます。
これでテンプレートは準備できました。
Creating gem 'openai_single_chat'...
MIT License enabled in config
Code of conduct enabled in config
Changelog enabled in config
RuboCop enabled in config
Initializing git repo in /Users/xxxxxxxxx/Documents/repository/openai_single_chat
create openai_single_chat/Gemfile
create openai_single_chat/lib/openai_single_chat.rb
create openai_single_chat/lib/openai_single_chat/version.rb
create openai_single_chat/sig/openai_single_chat.rbs
create openai_single_chat/openai_single_chat.gemspec
create openai_single_chat/Rakefile
create openai_single_chat/README.md
create openai_single_chat/bin/console
create openai_single_chat/bin/setup
create openai_single_chat/.gitignore
create openai_single_chat/.rspec
create openai_single_chat/spec/spec_helper.rb
create openai_single_chat/spec/openai_single_chat_spec.rb
create openai_single_chat/.github/workflows/main.yml
create openai_single_chat/LICENSE.txt
create openai_single_chat/CODE_OF_CONDUCT.md
create openai_single_chat/CHANGELOG.md
create openai_single_chat/.rubocop.yml
Gem 'openai_single_chat' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.html
メタデータを入力する
gemのメタデータを入力します。GithbページURLやRubyのバージョン、開発者の連絡先など。これらはRubyGem.orgでも該当gemページの右側に表示されます。
下記はすでに入力してしまっていますが、デフォルトではTO DO~
と書かれている箇所があるのでそこを埋めていけば良いです。(ファイル名は各自異なります)
# frozen_string_literal: true
require_relative "lib/openai_single_chat/version"
Gem::Specification.new do |spec|
spec.name = "openai_single_chat"
spec.version = OpenaiSingleChat::VERSION
spec.authors = ["xxxxxxxxxxxx"] #あなたの名前
spec.email = ["xxxxxxxxxxxxx@gmail.com"] #あなたの連絡先
spec.summary = %q{xxxxxxxxxxxxxxxxxx} #gemの概要
spec.description = %q{xxxxxxxxxxxxxx} #gemの説明
spec.homepage = "xxxxxxxxxxxxx" #gemのページ
spec.license = "MIT"
spec.required_ruby_version = ">= 2.6.0" #rubyのバージョン指定
spec.metadata["allowed_push_host"] = "https://rubygems.org" #rubygems.orgでOK
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "xxxxxxxxxxxxx" #ソースコード。Githubリポジトリで可
# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
spec.files = Dir.chdir(__dir__) do
`git ls-files -z`.split("\x0").reject do |f|
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
end
end
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
# Uncomment to register a new dependency of your gem
spec.add_dependency "httparty", "~> 0.18" # 依存関係にある他gem
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
end
バージョンを明記する
module OpenaiSingleChat
VERSION = "0.1.0"
end
メインになるコードを書く
gem名を参考にして、モジュール名がすでに明記されているのが便利に感じるところ
依存関係にある他gemのrequire
を忘れないようにします
# frozen_string_literal: true
require_relative "openai_single_chat/version"
require 'httparty'
require 'json'
module OpenaiSingleChat
class Error < StandardError; end
class Client
include HTTParty
base_uri 'https://api.openai.com/v1'
format :json
def initialize(model = 'gpt-4o-mini')
@model = model
@options = {
headers: {
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{ENV['OPENAI_API']}"
}
}
end
def chat(message, system_message = nil)
messages = []
messages << { role: 'system', content: system_message } if system_message
messages << { role: 'user', content: message }
body = {
model: @model,
messages: messages
}.to_json
response = self.class.post('/chat/completions', body: body, headers: @options[:headers])
if response.success?
response.parsed_response['choices'][0]['message']['content']
else
handle_error_response(response)
end
end
private
def handle_error_response(response)
error_message = response.parsed_response['error']['message'] rescue '不明なエラーが発生しました'
raise Error, "OpenAI API Error: #{error_message}"
end
end
end
ビルドする
開発環境でテストするためにgemをインストールします
gem build openai_single_chat.gemspec
gem install ./openai_single_chat-0.1.0.gem
ここから先、テスト中に不具合が見つかってコードを修正した場合は、またビルドからやり直します。
プチテストする(省略可)
一応動くのか確認するためにrubyのコンソールで簡単に確認します。
# 今回の場合はAPIキーとして環境変数が必要なので
export OPENAI_API=xxxxxxxxxxxxxxxxxxxxx
irb
irb(main):001:0> require 'openai_single_chat'
client = OpenaiSingleChat::Client.new
irb(main):003:0> response = client.chat("日本で一番高い山は?")
=> "日本で一番高い山は富士山(ふじさん)です。標高は3,776メートルで、静岡県と山梨県の県境に位置しています。富士山はその美し...
動いていそうです。
ちゃんとテストする
人様に配布するものなのでしっかり動作検証しておきましょう。
Gemfile
gem 'webmock'
HTTPリクエスト自体をモック化するライブラリです。今回はAPIを叩く動作があるのでテスト時に導入しました。特に関係ない場合は不要です。
大きくいじる箇所はないかと思いますが、今回はwebmockに関連して2行追加しています。
require "openai_single_chat"
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = ".rspec_status"
# Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching!
config.expect_with :rspec do |c|
c.syntax = :expect
end
end
テストコード
テストはgemによって内容が全く変わるので参考まで。
require 'spec_helper'
require 'openai_single_chat'
RSpec.describe OpenaiSingleChat::Client do
let(:api_key) { 'dummy_api_key' }
let(:client) { described_class.new }
before do
allow(ENV).to receive(:[]).with('OPENAI_API').and_return(api_key)
end
describe '#chat' do
let(:message) { "こんにちは, AI" }
let(:response_body) do
{
choices: [
{
message: {
content: "こんにちは、どうかしましたか"
}
}
]
}.to_json
end
before do
stub_request(:post, "https://api.openai.com/v1/chat/completions")
.with(
body: {
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }]
}.to_json,
headers: {
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{api_key}"
}
)
.to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' })
end
it 'returns the AI response' do
expect(client.chat(message)).to eq("こんにちは、どうかしましたか")
end
end
context 'when API returns an error' do
let(:error_response) do
{
error: {
message: "Invalid API key"
}
}.to_json
end
before do
stub_request(:post, "https://api.openai.com/v1/chat/completions")
.to_return(status: 401, body: error_response, headers: { 'Content-Type' => 'application/json' })
end
it 'raises an OpenaiSingleChat::Error' do
expect { client.chat("こんにちは") }.to raise_error(OpenaiSingleChat::Error, "OpenAI API Error: Invalid API key")
end
end
end
テストを実行
bundle exec rspec
bundle exec rspec spec/openai_single_chat_spec.rb
xxxxxxxxx@xxxxxxxxxxxiMac openai_single_chat % bundle exec rspec
OpenaiSingleChat::Client
#chat
returns the AI response
when API returns an error
raises an OpenaiSingleChat::Error
Finished in 0.04582 seconds (files took 0.34124 seconds to load)
2 examples, 0 failures
テストは通りました
gemを公開する
RubyGemsにgemを公開する際にはまずAPIキーを取得する必要があります
- RubyGems.orgのアカウントを作成します(まだ持っていない場合)
- RubyGems.orgにログインし、アカウント設定ページに移動します
- "API KEYS"セクションをクリックします
- "New API Key"をクリックして新しいAPIキーを作成します
- APIキーはメモを取っておきましょう
ターミナルで以下のコマンドを実行し、環境変数にAPIキーを設定します
export GEM_HOST_API_KEY=あなたのAPIキー
以下のコマンドでgemをプッシュします。gem名は変えてください
gem push your_gem_name
エラーが出なければアップ完了です。rubygems.orgを確認しましょう
動作確認(任意)
適当なrailsアプリ環境を構築し、本当に使えるのかを確かめます。
(環境構築は割愛)
gem 'openai_single_chat'
bundle install
.env
にOpenAI APIキーを記述
OPENAI_API=xxxxxxxxxxxx
コントローラでモジュールを呼び出し
require 'openai_single_chat'
class ChatController < ApplicationController
def index
if params[:message]
client = OpenaiSingleChat::Client.new
@response = client.chat(params[:message])
end
end
end
ビューで動作確認
<h1>Chat with AI</h1>
<%= form_with url: chat_index_path, method: :get, local: true do |f| %>
<%= f.text_field :message, placeholder: 'Enter your message' %>
<%= f.submit 'Send' %>
<% end %>
<% if @response %>
<h2>AI Response:</h2>
<p><%= @response %></p>
<% end %>
正常に動きました。
gemの行方(おまけ)
そういえば、gemってbundle install
した後どこに行くのか気になってので調査しました。
先に答えをいうと../usr/local/bundle/gems
にあります。
# dockerでデモアプリを作ったのでコンテナ内で確認
docker compose exec web bash
root@23bca0915897:/myapp# ls ../usr/local/bundle/gems
略
openai_single_chat-0.1.0
略
root@23bca0915897:/myapp#
たくさんのgemの中に確かにopenai_single_chat-0.1.0
がありました。
さらにいうと../usr/local/bundle/gems/openai_single_chat-0.1.0/lib/openai_single_chat.rb
にgem本体のコードがあります。cat
で表示させてみます。
root@23bca0915897:/myapp# cat ../usr/local/bundle/gems/openai_single_chat-0.1.0/lib/openai_single_chat.rb
require_relative "openai_single_chat/version"
require 'httparty'
require 'json'
module OpenaiSingleChat
class Error < StandardError; end
class Client
include HTTParty
base_uri 'https://api.openai.com/v1'
format :json
以下略
書いたコードがまるまるインストールされていることがわかります。なんてことはない、gemだってrubyコードなんだということです。
まとめ
gemを作るというとなんか凄いことのように思えてしまいますが、ただ作るだけであれば出来なくもないということがわかりました。もちろん普段使わせていただいているgemはこんな単純な構造ではないはずなので、あくまでも開発プロセスがわかったという程度ですが。
やってみてよかったなと思うのは、Ruby(on rails)の構造、モジュール設計、テスト、ドキュメンテーション、OSSの作法など、ちゃんと考えようと思えたことです。個人のミニアプリ開発もこれくらい真剣にやらないとなあと...
この記事を書いた人