Pythonのtry-else句と、テストカバレッジ100%が必ずしも品質を保証しないという話

こんにちは。fastAPI歴○か月のケイ太です。

先日、テストカバレッジも大事だけれど、それだけで品質を保証できるわけではない、ということを実感することがありました。

きっかけはこんなコードです。

def create_users(db:Session, users_list: List[UserCreate]) -> List[Users]:
    try:
        new_users = [create_user_instance(user) for user in users_list]
        db.bulk_save_objects(new_users)
    except Exception as e:
        db.rollback()
        return f"An error occurred: {str(e)}"
    else
        db.commit()
    
    return users_instances    

ぱっと見、問題なさそう…?と思いましたが、少し気になるポイントが。db.commit()がelse句の中にあります。「これって本当に大丈夫なのかな?」と思って調べてみました。

else句はどういう時に使うのか確認すると、

8. エラーと例外 — Python 3.12.0 ドキュメント

try 文は下記のように動作します。 まず、 try 節 (try clause) (キーワード try と except の間の文) が実行されます。 何も例外が発生しなければ、 except 節 をスキップして try 文の実行を終えます。(中略)
try ... except 文には、オプションで else 節 (else clause) を設けることができます。 else 節を設ける場合、全ての except 節よりも後ろに置かなければなりません。 else 節は try 節 で全く例外が送出されなかったときに実行されるコードを書くのに役立ちます。

とあります。 db.commit()自体が例外を発生させる可能性があるのに、else句の中に置いてしまうと、予期しないエラーや不整合が起きる可能性がありそうです。db.commit()はtry句の中に書かないといけない、ということです。

このコードのように、例外処理やロジックが間違っていたりしても、カバレッジでは補足できません。コードの質やロジックの正しさに注意を向けずに、カバレッジの数字だけ追ってもあまり意味はないのですね。

RubyでWebサーバを作ってみた2:静的なサーバを完成させる

ここまでで、CSSや画像を含むページを表示出来るようになりました。 今回も

gihyo.jp

の書籍を参考に、Ruby

を実装して、静的なサーバを完成させたいと思います。

目次

動作環境

実装

URLエンコードの対応をする

URLエンコードは、URLに含まれるスペース、記号、日本語文字などの特殊な文字を安全に表現するための方法です。 これらの文字をURLエンコードすることで、安全にURLに組み込むことができます。 サーバーサイドでは、URLエンコードされたパスをデコードして、正しいファイルパスを取得します。

URI.decodeは非推奨

URI.escape is obsolete. Percent-encoding your query stringによると、 URI.encodeは、文字列全体を単純にgsubで置換しているだけで、RFC-3896の仕様に準拠しているわけではないようで、URI.decodeも非推奨になっています。

今回は、CGI仕様の、CGI.unescapeを使おうと思います。
(RFC 3986, RFC 3987, and RFC 6570に準拠しているaddressableもあります。)

CGI.unescapeでdecodeする

  def recv
    request = @client.gets
    @path = request.split[1] if request.start_with?('GET')
    request = @client.gets until request.chomp.empty?
     # 追加
    @path = CGI.unescape(@path)
  end

日本語\index.htmlを用意する

C:\mywebsite\フォルダに「日本語」というフォルダを作成して、その中にindex.htmlを用意しました。

// \mywebsite\日本語\index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        body {
            background-color: #66ccff;
            text-align: center;
            padding: 20px;
        }
        img {
            width: 80px;
            height: auto;
        }
        a {
            font-size: 50%;
        }
    </style>
</head>
<body>
    日本語 works💐<br>
</body>
</html>

確認

  • ブラウザでhttp://localhost:8001/日本語/index.htmlをたたいてみます
    非Ascii文字の入ったURLも処理できるようになりました。

参考サイト

ファイルが存在しない時に404 NOT FOUNDを返す

存在しないファイルをリクエストされた場合の対応

@pathで指定されたファイルが存在した時はステータスコード200を、ファイルが存在しなかったときはステータスコード404を返すようにします

def send_response
    if File.exist?("#{DOCUMENT_ROOT}#{@path}")
      generate_response('200 OK', "#{DOCUMENT_ROOT}#{@path}")
    else
      generate_response('404 Not Found', "#{DOCUMENT_ROOT}/404.html")
    end
  end

  def generate_response(status_code, file_path)
  # レスポンスの中身
  end

404.htmlを用意する

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Not Found</title>
    <style>
        body {
            text-align: center;
            font-size:xx-large;
            padding: 20px;
        }
        
    </style>
</head>
<body>
    <h1>404<br>Not Found</h1></body>
</html>

確認

存在しないURLhttp://localhost:8001/foo.htmlを叩いて、404が返ってくることを確認します。
404が返ってきました

ディレクトリトラバーサル脆弱性に対応する

ディレクトリトラバーサルとは、相対パス表記を用いることで、本来アクセスできるはずのないディレクトリやファイルにアクセス出来てしまうことです。 この脆弱性から、機密情報が漏洩したり、システムの不正な操作が可能になったりする可能性があります。

今回は、上記のコードで、file_pathにドキュメントルートからの絶対パスを指定しているので、相対パスによるディレクトリトラバーサル攻撃は遮断されるはずです。
試しに、

  • http://localhost:8001/../
  • http://localhost:8001/../foo/

等をたたいても、ディレクトリトラバーサルはできません。この対応もできました。

ドメインだけ/ディレクトリだけ指定されたときに対応する

ディレクトリまで指定されたときは、デフォルトでindex.htmlを返す

def send_response
    @path += 'index.html' if @path.end_with?('/')

   (略)
 end

@path/で終わっている時には@pathindex.htmlを加えるようにします。
これで、ルートパスだけ指定されたときでも、トップページを表示できます

users\index.htmlを用意する

C:\mywebsite\フォルダに「users」というフォルダを作成して、その中にindex.htmlを用意しました。

// \mywebsite\users\index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Users</title>
    <style>
        body {
            background-color: #fffce6;
            text-align: center;
            padding: 20px;
        }
        img {
            width: 80px;
            height: auto;
        }
        a {
            font-size: 50%;
        }
    </style>
</head>
<body>
    cat<br>
</body>
</html>

確認

  • http://localhost:8001/users/index.html
  • http://localhost:8001/users/

を叩くと、同じページが表示されることが確認できました。

ディレクトリの後に/を付けずに指定されたときはリダイレクトする

次は、http://localhost:8001/usersと指定されたときです。

仕様の確認

試しにhttps://ja.wikipedia.org/wikiと、末尾に'/'無しでたたいてみると、

ということが分かります

仕様書の確認

仕様書も確認しておきましょう。 RFC 7231 - Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content

The 301 (Moved Permanently) status code indicates that the target resource has been assigned a new permanent URI and any future references to this resource ought to use one of the enclosed URIs.
(略)
The server SHOULD generate a Location header field in the response containing a preferred URI reference for the new permanent URI. The user agent MAY use the Location field value for automatic redirection. The server's response payload usually contains a short hypertext note with a hyperlink to the new URI(s).

ステータスコード301の役割は、クライアントに対して要求されたリソースが恒久的に新しいURLに移動したことを通知することと、レスポンスに新しいURLを示すことであり、新しいURLに対してリダイレクトを行うかどうかは、クライアントの判断次第ということのようです。

実装

def send_response
    @path += 'index.html' if @path.end_with?('/')

    if Dir.exist?("#{DOCUMENT_ROOT}#{@path}/")
      @path += '/index.html'
      generate_response('301 Moved Permanently', "#{DOCUMENT_ROOT}#{@path}")
    elsif File.exist?("#{DOCUMENT_ROOT}#{@path}")
      generate_response('200 OK', "#{DOCUMENT_ROOT}#{@path}")
    else
      generate_response('404 Not Found', "#{DOCUMENT_ROOT}/404.html")
    end
  end

Dir.exist?("#{DOCUMENT_ROOT}#{@path}/")で リクエストされたパスが存在するディレクトリであれば、そのパスの末尾にindex.htmlを追加し、 generate_response@pathを渡します。

  def generate_response(status_code, file_path)
    response = "HTTP/1.0 #{status_code}\r\n"
    response += "Date: #{Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
    response += "Server: Modoki/0.1\r\n"

    if status_code == '301 Moved Permanently'
      response += "Location: http://#{HOST_NAME}:#{PORT_NUMBER}#{@path}\r\n"
      response += "\r\n"
    else

      response += "Content-Type: #{content_type}\r\n"
      response += "\r\n"

      content = read_file(file_path)
      response += content
    end
    @client.puts(response)
  end

ステータスコードが 301 Moved Permanently のとき、Location ヘッダフィールドをレスポンスに追加します。

確認

http://localhost:8001/usersをたたいてみます リダイレクトが出来ていることがわかります これで静的な最低限のサーバ機能が揃ったと思います。
次はWebアプリケーションを扱えるWebサーバを作ってみます

ソースコード

https://github.com/kei-kmj/HenacatRuby/commit/072cbc63f326df91a181f57cbff8e9d9f8e45f40

RubyでWebサーバを作ってみた

gihyo.jp

この書籍を参考に、RubyでWebサーバを作ってみようと思います。

目次

動作環境

実装

実装のゴールの確認

WebサーバはブラウザからのHTTPリクエストに対し、HTTPレスポンスを返すのが仕事です。 具体的には

  1. ステータスライン
  2. レスポンスヘッダー
  3. 空行
  4. 要求されたパスの内容

をクライアントに返します。

今回はまず、前回作ったTCPサーバ/クライアントをベースに、クライアントに上記の内容を返すことを目指します。

初期化

class Modoki
  # 初期化
  def initialize
    @server = TCPServer.open(8001)
    @client = nil
    @path = nil
  end
end

TCPServerRubyの標準ライブラリで、TCPプロトコルを使用したサーバーを作成することができます。 openメソッドは新しいサーバーを作成し、指定したポート(ここでは8001)で接続を待ち受けます。 @clientは、後でTCP接続のクライアントを保存し、@pathはHTTPリクエストから取得したリクエストパスを保存するために用意します。

待機

def accept
    @client = @server.accept
end

acceptメソッドは、クライアントからの接続があるまで処理をブロックします。 接続があるとクライアントとの通信用のソケットを返すので、それを@client変数に代入します。

リクエスト受信

def recv
   request = @client.gets
   @path = request.split[1] if request.start_with?('GET')
   request = @client.gets until request.chomp.empty?
end

getsメソッドは、接続済みのクライアント(@client)からHTTPリクエストの1行目を受け取ります。 リクエストが'GET'で始まる場合は、リクエストを空白文字で分割しリクエストのパスを@pathに代入し、 リクエストが空行になるまで1行のデータを受け取り、それを変数requestに代入し続けます。

レスポンス送信

def send
    # ヘッダーを送信する
    @client.puts("HTTP/1.0 200 OK\r\n")
    @client.puts("Date: #{Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n")
    @client.puts("Server: Modoki/0.1\r\n")
    @client.puts("Content-Type: text/html\r\n")
    @client.puts("\r\n")

    # ボディを送信する
    File.open("#{DOCUMENT_ROOT}#{@path}", 'r') do |f|
      f.each_line { |line| @client.puts(line) }
    end
end

ヘッダーとボディを送信します。 ボディは、ドキュメントルート(DOCUMENT_ROOT)と、@pathを連結したパスを作成し、そのファイルから1バイトずつ読み込んでクライアントに送信しています。

切断

def close
    @client.close
end

最後にクライアントとの接続を切断します

全体のコード

require 'socket'

class Modoki
  # ドキュメントルートの設定
  DOCUMENT_ROOT = '/mnt/c/mywebsite'

  # 初期化
  def initialize
    @server = TCPServer.open(8001)
    @client = nil
    @path = nil
  end

  def run
    accept
    recv
    send
    close
  end

  private

  def accept
    # クライアントからの接続を待つ
    @client = @server.accept
  end

  def recv
    # クライアントからのHTTPリクエストを受信する
    # 空行が来るまで受信する
    request = @client.gets
    @path = request.split[1] if request.start_with?('GET')
    request = @client.gets until request.chomp.empty?
  end

  def send
    # クライアントにHTTPレスポンスを送信する
    # ファイルが存在する場合は、ファイルの内容を送信する
    # ヘッダーを送信する
    @client.puts("HTTP/1.0 200 OK\r\n")
    @client.puts("Date: #{Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n")
    @client.puts("Server: Modoki/0.1\r\n")
    @client.puts("Content-Type: text/html\r\n")
    @client.puts("\r\n")

    # ボディを送信する
    File.open("#{DOCUMENT_ROOT}#{@path}", 'r') do |f|
      f.each_line { |line| @client.puts(line) }
    end
  end

  def close
    # クライアントとの接続を切断する
    @client.close
  end
end

### サーバーの起動
server = Modoki.new
server.run

index.htmlを用意する

C:\mywebsite\フォルダにindex.htmlを用意しました

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>It works🎉
</body>
</html>

起動

$ ruby Modoki/modoki.rb

確認

ブラウザでhttp://localhost:8001/index.htmlを叩くと、
成功しました😄

ソースコード

https://github.com/kei-kmj/HenacatRuby/pull/3/commits/d93d49d1c3b8451d690a05d11d5d488c0ff49800

モドキじゃないサーバへ

1つのHTMLファイルを返し、ブラウザで表示することが出来ました。 次は

に対応してみたいと思います。

実装

画像やCSSなどを送信できるようにする

リクエストの拡張子に応じてMIMEタイプを変えないといけません。

 @client.puts("Content-Type:  text/html\r\n")

これを

 @client.puts("Content-Type: #{content_type}\r\n")

にしてcontent_typeメソッドを書いていきます。

EXTENSION_TO_MIME = 
    {
      'html' => 'text/html',
      'txt' => 'text/plain',
      'png' => 'image/png',
      'jpg' => 'image/jpg',
      'jpeg' => 'image/jpeg',
      'gif' => 'image/gif',
      'css' => 'text/css',
      'js' => 'text/javascript'
    }.freeze

def content_type
    ext = @path.split('.')[-1]
    EXTENSION_TO_MIME[ext] || 'text/plain'
end 

リクエストされたパスからファイルの拡張子を取得し、対応するMIMEタイプを EXTENSION_TO_MIMEハッシュから引き出し、 対応するMIMEタイプが見つからない場合は、デフォルトとして 'text/plain' を返します。

TCP接続を繰り返し受け付けるようにする

# frozen_string_literal: true

require_relative 'server_thread'
require 'socket'

class Server
  # ドキュメントルートの設定
  DOCUMENT_ROOT = '/mnt/c/mywebsite'

  # 初期化
  def initialize
    @server = TCPServer.open(8001)
  end

  def run
    loop do
      Thread.start(@server.accept) do |client|
        ServerThread.new(client).process_request
      end
    end
  end
end

## サーバーの起動
server = Server.new
server.run

クライアントからの接続を受け続けるServerクラスを用意しました。 Thread.startメソッドを使うことで、各クライアントからのリクエストがそれぞれ独立したスレッドで処理されるので、多数のクライアントからのリクエストを効率良く処理できます。 実際のリクエストの処理はServerThreadが担当します。

HTMLファイルの用意

index.htmlを書き換え

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        body {
            background-color: #fffce6;
            text-align: center;
            padding: 20px;
        }
        img {
            width: 80px;
            height: auto;
        }
        a {
            font-size: 50%;
        }
    </style>
</head>
<body>
    It works<br>
    <img src="\blob_cheer.jpg" alt="blob_cheer"><br>
    <a href="next.html">次のページ</a>
</body>
</html>

css、画像、リンクを書き足してみました。

next.htmlを用意する

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Next</title>
    <style>
        body {
            background-color: #fffce6;
            text-align: center;
            padding: 20px;
        }
        img {
            width: 30px;
            height: auto;
        }
    </style>
</head>
<body>工事中です 🙇<200d>♀️</body>
</html>

起動

$ ruby FlimsyServer/server.rb

確認

CSSも画像も表示でき、リンクも辿れました🎉

ソースコード

Implement web server by kei-kmj · Pull Request #3 · kei-kmj/HenacatRuby · GitHub


  • 画像の表示が遅いです。まるで20年前のWebサイトに訪問したかのようです。画像データも1行ずつ送っているためだと思われます。バッファリングなどを考えれば良いのかもしれませんが、今回の目的から外れるので、ここでは対応しません。
  • 次回、ディレクトリトラバーサル脆弱性、404エラーに対応していきたいと思います

RubyでTCPサーバ/クライアントを作ってみた

gihyo.jp この書籍を参考に、RubyTCPサーバ/クライアントを作ってみました。

目次

TCPとソケット

TCP(Transmission Control Protocol)は、コンピュータネットワーク上で信頼性のあるデータ転送を提供する通信プロトコルです。TCP通信には、ソケット(socket)というAPIを使用します。サーバ側ソケットがクライアントからの接続を待ち受け、クライアント側ソケットでサーバのホストとポートを指定して接続します。これにより、双方向のデータ送信が可能な通信経路が確立されます。

今回は、Rubysocketライブラリを利用して、簡単なTCPサーバ/クライアントを作ってみました。

動作環境

サーバ側

初期化

class TcpServer
  def initialize
    @server = TCPServer.open(8001)
    @client = nil
  end
end

TCPServer クラスのopenメソッドを使用して、ポート番号 8001 での接続を待ち受けるサーバーソケットを作成します。 TCPServer.openメソッドは、指定したポート番号にバインドされた新しい TCPServer オブジェクトを返します。 これでクライアントからの接続を受け付ける準備が整います。

クライアントとの接続を表すソケットを初期化するために、@clientインスタンス変数を nilで初期化しています。 この変数は、クライアントとの接続が確立された後にソケットオブジェクトを保持するために使用します。

待機

def accept
    puts 'waiting for connection...'
    @client = @server.accept
end

acceptメソッドは、クライアントからの接続があるまで処理をブロックします。 接続があるとクライアントとの通信用のソケットを返すので、それを@client変数に代入します。

データ受信

def recv
    puts 'connected'
    # 受け取った内容をsever_recv.txtに書き込む
    File.open('server_recv.txt', 'w') do |f|
      while line = @client.gets
        break if line.chomp == '0'
        f.write(line)
      end
    end
 end

TCPサーバー内でクライアントからのデータを受信し、それを server_recv.txtファイルに書き込むための処理を行っています。 ファイルを書き込みモードで開きます。 @client.getsで、クライアントからのデータを1行ずつ取得し、受信したデータが '0'のときにループを終了します。 受信したデータを server_recv.txtファイルに書き込みます。

データ送信

def send(_data)
    File.open('server_send.txt', 'r') do |f|
      while line = f.gets
        @client.puts(line)
      end
    end
end

server_send.txtファイルを読み込みモードで開き、ファイルから行ごとにデータを読み込みます。 putsは、クライアントのソケットに対してデータを送信するメソッドで、ファイルから読み込んだ行ごとにクライアントにデータを送信しています。

切断

def close
    @client.close
end

切断します。

ソースコード

require 'socket'

# tcpサーバークラス
class TcpServer
  # 初期化
  def initialize
    @server = TCPServer.open(8001)
    @client = nil
  end

  def run
    accept
    recv
    send('server_send.txt')
    close
  end

  private

  # クライアントからの接続を待つ
  def accept
    puts 'waiting for connection...'
    
    @client = @server.accept
  end

  # クライアントからのデータを受信する
  def recv
    puts 'connected'
    # 受け取った内容をsever_recv.txtに書き込む
    File.open('server_recv.txt', 'w') do |f|
      while line = @client.gets
        break if line.chomp == '0'
        f.write(line)
      end
    end
  end

  # クライアントにデータを送信する
  def send(_data)
    File.open('server_send.txt', 'r') do |f|
      while line = f.gets
        @client.puts(line)
      end
    end
  end

  def close
    puts 'connection closed'
    @client.close
  end
end

# サーバーの起動
server = TcpServer.new
server.run

クライアント側

初期化

class TcpClient
  # 初期化
  def initialize
    @client = TCPSocket.open('localhost', 8001)
  end
end

TCPSocket.openメソッドを使用して、指定されたホスト名とポート番号でソケットを開きます。

送信

def send(_data)
    # client_send.txtの内容をサーバーに送信する
    File.open('client_send.txt', 'r') do |f|
      while line = f.gets
        @client.puts(line)
      end
    end
    # サーバーに0を送信する
    @client.puts('0')
  end

putsは、サーバーのソケットに対してデータを送信するメソッドで、ファイルから読み込んだ行ごとにサーバーにデータを送信しています。 最後にサーバーに '0' を送信し、通信の終了を示します。

ソースコード

require 'socket'

# tcpクライエントクラス
class TcpClient
  # 初期化
  def initialize
    @client = TCPSocket.open('localhost', 8001)
  end

  def run
    send('client_send.txt')
    recv
    close
  end

  private

  def send(_data)
    # client_send.txtの内容をサーバーに送信する
    File.open('client_send.txt', 'r') do |f|
      while line = f.gets
        @client.puts(line)
      end
    end
    # サーバーに0を送信する
    @client.puts('0')
  end

  def recv
    # サーバーからのデータを受信する
    File.open('client_recv.txt', 'w') do |f|
      while line = @client.gets
        f.write(line)
      end
    end
  end

  def close
    @client.close
  end
end

# クライアントの起動
client = TcpClient.new
client.run

実行

client_send.txtserver_send.txtファイルを用意する

  • client_send.txt
My name is Mike
  • server_send.txt
Hello, Mike!

起動

  • サーバ側
$ ruby TCP/tcp_server.rb
waiting for connection...
  • クライアント側
$ ruby TCP/tcp_client.rb

確認

server_send.txtと同内容のclient_recv.txtが出来ていれば成功です。

TCPサーバをWebブラウザで叩く

tcp_server.rbを本物のWebブラウザで叩いてみます。

サーバ起動

$ ruby TCP/tcp_server.rb

URL入力

ブラウザにhttp://localhost:8001/index.htmlと入力する

確認

server_recv.txtでHTTPリクエストを確認できます。

TCPクライアントでWebサーバを叩く

次はWebサーバがブラウザに対して何を返すのか確認してみます。 書籍ではApacheを使っていますが、面倒なので、実際のWebサイトのアドレスを叩いてみます。

tcp_client.rbの書き換え

def initialize
    @client = TCPSocket.new(ENV.fetch('WEBSITE', nil), 80)
end

TCPSocket.new メソッドを使用して、指定されたホストとポート番号での新しいソケットを作成します。 secureでない接続が出来てしまうサイトを晒すのもどうかと思うので、URLは環境変数にして読み込んでいます。

def send
    request = "GET / HTTP/1.1\r\n"
    request += ENV.fetch('HOST', nil)
    request += "Connection: close\r\n\r\n"

    @client.puts(request)
 end

sendメソッドも書き換えます。

# @client.puts('0')

'0'の送信は不要なのでコメントアウトします

起動

$ ruby TCP/tcp_client.rb

確認

client_recv.txtにHPPTレスポンスが出力されたら成功です。

思ったより簡単にできました🎉

ソースコード

https://github.com/kei-kmj/HenacatRuby/commit/def12ca9a209686fd1b9910947a9f653f5b42c5f

RailsプロジェクトのRubyのバージョンを上げたときのメモ

RailsプロジェクトのRubyのバージョンを3.1.1から3.2.2に上げたときのメモです。

目次

環境

Ruby3.2.2のインストール

$ rbenv install --list


  • バージョンを指定してインストール
$ rbenv install 3.2.2


  • 反映
$ rbenv rehash


  • 確認
$ rbenv versions
system
  2.6.6
  2.7.1
  2.7.4
  3.0.0
  3.0.1
  3.0.2
  3.1.0
* 3.1.1 (set by /)
  3.2.2

更新前チェック

  • ブランチを切る
$ git checkout -b update-ruby-version-to-3.2.2


  • テストがパスすることを確認する
# 非推奨の機能が使われていたら警告を出す。
 RUBYOPT=-W:deprecated bundle exec rspec
..

Finished in 0.24363 seconds (files took 0.66897 seconds to load)
2 examples, 0 failures

Rubyのバージョンを上げる

$ rbenv local 3.2.2


# .ruby-version
3.2.2


# Gemfile
ruby '3.2.2'


  • gemを再インストール
bundle i


  • テストが通ることを確認
$ RUBYOPT=-W:deprecated bundle exec rspec
..

Finished in 0.32309 seconds (files took 1.71 seconds to load)
2 examples, 0 failures


  • .github/workflowsruby-versionも修正
name: Test
on: [ push ]
jobs:
  rspec:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2.2'
      - name: Install dependencies
        run: bundle install
      - name: Run tests
        run: bundle exec rspec
  lint:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Install Ruby and gems
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.2.2"
          bundler-cache: true
      - name: Rubocop
        run: bundle exec rubocop


  • Dockerfileを修正
FROM ruby:3.2.2

本番でも動作確認できたので終了です🎉

参考にしたサイト

開発テーマは速い!安い!メンテナンス楽々! ~漢検学習アプリをリリースしました~

1年間のフィヨルドブートキャンプでの学習の仕上げとして、漢検学習アプリ『漢検練習帳』を作成しました。

漢検練習帳 | クイズで漢検

目次

1. 仕様

  • ユーザー登録不要です。
  • 4択クイズ形式で漢検準1級/1級範囲の漢字の学習ができます。
  • 問題の級と分野を選択すると、問題がランダムで10問出題されます。
  • ☑復習するにチェックを入れた問題だけを繰り返し練習できます。

2. 開発環境

テスト

  • RSpec 3.12
  • Cypress 11.2.0
  • Vitest 0.25.8

3. 開発テーマ

趣味サイトとして作るという大前提のもと、

  1. 自分の実力の及ぶ範囲で
  2. 出来る限り新しいバージョンのフレームワーク/ライブラリを使いつつ
  3. 速い!安い!メンテナンス楽々!

を実現することをテーマにしました。

3.1 自分の実力の及ぶ範囲で

フィヨルドブートキャンプでこんなカリキュラムを学習しました。 bootcamp.fjord.jp

3.2 出来る限り新しいバージョンのフレームワーク/ライブラリを使いつつ

開発環境のとおり、だいたい最新のバージョンを使えていると思います

3.3 速い!安い!メンテナンス楽々!

3.3.1 速い

  • シングルページアプリケーション(SPA)で作成
    ユーザーが速く、次々とクイズを解いていけるように、SPAで作成しました。

  • ビルドツールとしてViteを採用
    初回起動とビルドの速さを実現するために、Viteを採用しました。

  • フロントエンドテストツールVitestを利用
    Vitestは、Viteのパイプラインとして実行される高速テストツールで、Jestと同じように使えるということなので使ってみることにしました。

3.3.2 安い

これで100円/月未満で運用できる見込みです。

(※) SQLite3にはレプリケーション機能が無いので、LitestreamでレプリケーションすることでSQLite3を本番環境で利用できるようにしています。
Why I Built Litestream - Litestream

3.3.3 メンテナンス楽々

  • csvファイルでコンテンツ管理
    クイズコンテンツのメンテナンス時に、都度RailsコンソールでSQL実行するのは面倒なので、csvファイルでコンテンツの追加、修正などを出来るようにしました。
    RailsのSQLite3テーブルをCSVでインポート/エクスポートする - ケイ太のDX備忘日記

  • モノレポでデプロイ先も1か所
    レポジトリをモノレポにして、データベースごとCloud Runにデプロイすることで、レポジトリやデプロイ先の管理が1か所で済むようにしました。
    ここは議論のあるところかもしれませんが、今回は一人で開発する小さなプロジェクトなので、管理物の数を少なくした方が楽だろうと判断しました。

  • Next.jsを使わない
    Next.jsは便利ですし、デプロイもVercelにすればワンストップで楽ちんです。
    しかし、productionモード、build、Docker、デプロイ等に関する知識があったうえでワンストップを利用するのとは違い、 何の知識もないままワンストップを頼ってしまえば、今後何か問題が出たときに、すぐに対処できる実力が自分に付いているとは限らなそうです。
    であれば、今の段階で基本的な知識を付けてしまうほうが、後々楽だろうと考え、Reactを使うことにしました。

4. 雑感

SQLite3+Litestream+Cloud Runという選択

同時に80リクエストまで耐えられるので、予算制約の厳しい、社内用の小さなアプリ等はこの構成で開発できるのではないでしょうか 。 ただしコンテナを複数にしたときにDB間の整合性が取れない気がするので、コンテナは増やせそうにありません(未検証)

完成が10日遅れた

2か月で完成させるつもりで始めましたが、10日遅れて、70日かかっての完成となりました。期日は刻々と近づいているのに、デプロイ時のエラーが全く解消されない絶望感を味わいつつ、+10日のリスケをして開発しました。
Next.jsを使えば、3週間は開発期間を短くできたと思いますし、さらにデータベースも使わずJSONを使えば、Railsも不要になって、もっと早く完成したと思うので、早くリリースすることを重視するなら、こちらの構成が良かっただろうと思いました。

苦労したこと

  1. ProductionのビルドにPollyfillが必要なことがなかなか分からなかった
    Vite.jsの本番環境用のbuildでError: 'fileURLToPath' is not exported by __vite-browser-external, imported by node_modules/local-pkg/dist/shared.mjsを解決したメモ - ケイ太のDX備忘日記

  2. Docker Composeを使わず、DockerfileだけでRails + Nodeイメージをビルドすること
    GCP にはDocker Composeを利用してサービスを立ち上げる方法は存在しないようで、 DockerfileだけでRails + Nodeイメージをビルドしなければなりませんでした。
    しかし、書籍やネットの情報を調べても、Docker Composeを使用した例しか見つからず、Dockerに関する書籍
    プログラマのためのDocker教科書 第2版 インフラの基礎知識&コードによる環境構築の自動化(WINGSプロジェクト 阿佐 志保 山田 祥寛)|翔泳社の本
    試して学ぶ Dockerコンテナ開発 | マイナビブックス
    を読み込んで、1行1行書き進めました。

デザイン

いかにも個人開発っぽい、もっさりしたデザインに仕上がりました。
デザインに関しても、もっと学習が必要そうです。

5. 今後の課題

画面表示が遅い

SPAなので、ある程度仕方がないのかもしれませんが、最初の画面表示が遅いので、改善できるところは改善したいです。

やっぱりコンテンツ管理が面倒

csvファイルを利用することでクイズコンテンツを管理する手間は減っていますが、まだコンテンツの追加や修正は面倒です。RubyMineとの連携でもっと楽にメンテナンスできそうなので、研究してみようと思います。

6. ソースコード

https://github.com/kei-kmj/kanken_practice_note

はてなブログにカスタムcssをあてる

備忘のために、cssデザインの変更方法と変更内容をメモします

1. 変更方法

  • はてなブログにログインし、ダッシュボードからデザインを選択

  • カスタマイズボタンをクリック

  • 左下の{}デザインcssをクリック

  • 枠内にcssを貼り付けて保存する

2. css

/* <system section="theme" selected="report"> */
/* テーマをreportにして、本文の横幅を出来るだけ大きくとる*/
/* このテーマはレスポンシブデザイン非対応*/
/* [https://blog.hatena.ne.jp/-/store/theme/12921228815712830663:title] */


@import "/css/theme/report/report.css";
/* </system> */

/* <system section="background" selected="default"> */
/* default */
/* </system> */

/* for report */

/* フォント */
body {
    font-family: 'Helvetica Neue', 'Helvetica', 'Univers', 'Arial', 'Hiragino Kaku Gothic Pro', 'Meiryo', 'MS PGothic', sans-serif;
}

.entry-title a {
    font-size: 160%;
}

.entry-content {
    font-size: 16px;
}

.entry-content h1 {
  padding: 6px 10px;
  border-left: 8px solid #b8d200;
  border-bottom: 1px solid #b8d200;
  color: #333;
  line-height: 1.5;
  background-color: #f5f5f5;
}
.entry-content h2 {
  padding: 6px 10px;
  line-height: 1.5;
  background-color: #f5f5f5;
}
/* ソースコード表示 */
.entry-content pre{
  white-space: pre-wrap;
  word-wrap: break-word;
  background: #efefef;
}
.entry-content pre.code {
  font-size: 90%;
}

/* 引用表示 */
.entry blockquote {
  border-top: none;
  border-right: none;
  border-bottom: none;
  border-left: 3px solid #b3bfc7;
  padding: 2px 0 2px .7em;
  color: #626e77;
}

/* 画像表示 */
img{
display: inline-block;
box-sizing: border-box;
border: solid 1px #333;
}

@media screen and (min-width:1200px) {
    
#container{
    width: 95%;
}

#content {
  padding-left: 70px;
}

#content-inner {
  zoom: 1;
}
#content-inner:after {
  content: '';
  display: block;
  clear: both;
}
#wrapper {
  float: left;
  width: 100%;
   /*(サイドバーの幅 + 記事とサイドバーの間隔 ) × (-1) */
  margin-right: -220px; 
}
#wrapper #main {
  margin-left: 0px;
   /*サイドバーの幅 + 記事とサイドバーの間隔  */
  margin-right: 220px; 
}
#box2 {
  float: right;
   /*サイドバーの幅 */
  width: 180px; 
}
/* キーワード下線を消す */
a.keyword {
    border: transparent 0px;
}

ソースコード

kei-kmj.github.io/hatena_custom.css at main · kei-kmj/kei-kmj.github.io · GitHub

外部cssを読み込むことも出来るようなので、次はGithubと連携させてみたいと思います