iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 20
0
Modern Web

向 Rails 致敬!30天寫一個網頁框架,再拿來做一個 Todo List系列 第 20

[DAY 20] 復刻 Rails - 用 Rails 的方式整理程式碼 Active Record

  • 分享至 

  • xImage
  •  

前面 19 天我們寫了很多 code,但你會發現我們 lib 資料夾底下很亂,這是目前裡面所包含的東西

.
├── lib
│   ├── mavericks
│   │   ├── controller.rb
│   │   ├── data_record
│   │   │   ├── base.rb
│   │   │   ├── connection_adapter.rb
│   │   │   ├── method.rb
│   │   │   ├── persistence.rb
│   │   │   └── relation.rb
│   │   ├── data_record.rb
│   │   ├── dependencies.rb
│   │   ├── file_model.rb
│   │   ├── routing.rb
│   │   ├── sqlite_model.rb
│   │   ├── support.rb
│   │   └── version.rb
│   └── mavericks.rb

如果想要做一個框架出來,除了達到框架需要的功能以外,如何寫一個容易維護和擴充的程式碼也是相當重要,所以接下來會邊重構邊增加一些功能,實務上對於重構應該要搭配測試一起進行才對,不過這裡就不針對測試那塊著墨,有興趣的人可以自行搭配測試

重構 Action Record

我們今天的目標就是將 data_record.rb 改成 active_record.rb 版本

原先的 data_record.rb

# mavericks/lib/mavericks/data_record.rb

require 'mavericks/support'
require_relative "./data_record/relation"
require_relative "./data_record/connection_adapter.rb"
require_relative "./data_record/persistence"
require_relative "./data_record/method"
require_relative "./data_record/base"

module Mavericks
  module DataRecord
  end
end

會改成像是這樣

# mavericks/lib/active_record.rb

module ActiveRecord
  autoload :Mavericks, "mavericks/support"
  autoload :Base, "active_record/base"
  autoload :Persistence, "active_record/persistence"
  autoload :ConnectionAdapter, "active_record/connection_adapter"
  autoload :Relation, "active_record/relation"
end

會發現我們將 active_record 拉到跟 mavericks 一樣的層級,希望之後 active_record 也能獨立成一個套件,而這裡我們用 autoload 的好處是,如果有用到這個常數,才會把檔案給 load 進來,例如我們有用到 ActiveRecord::Base,才會載入 active_record/base.rb

而在 active_record 裡面我們會暫時用到 mavericks/support 來處理載入檔案的問題,但其實這做法並不好,因為我們原先的目的就是要把 active_record 獨立出來,不過現在先暫時這樣做,之後我們會在重新整理

接著修改 base.rb

# mavericks/lib/mavericks/data_record/base.rb

module Mavericks
  module DataRecord
    class Base
      include Persistence
      extend Method
    end
  end
end

我們原先的 base.rb 長這樣,用二分法將 class methodinstance method 做區隔,但這做法並不好,應該用 功能 做為 module 分開才對,這樣才能達到 reuse 的效果

# mavericks/lib/active_record/base.rb

require 'yaml'

module ActiveRecord
  class Base
    include Persistence

    def initialize(attributes = {})
      self.class.set_column_to_attribute
      @attributes = attributes
      @new_record = true
    end

    def new_record?
      @new_record
    end

    def self.establish_connection
      raw = File.read('config/database.yml')
      database_config = YAML.safe_load(raw)
      case database_config['default']['adapter']
      when 'postgresql'
        @@connection = ConnectionAdapter::PostgreSQLAdapter.new(database_config['development']['database'])
      when 'sqlite'
        @@connection = ConnectionAdapter::SQLiteAdapter.new(database_config['development']['database'])
      end
    end

    def self.connection
      @@connection
    end

    def self.set_column_to_attribute
      self.connection.schema(self.table_name).each{ |column| self.define_method_attribute(column) }
    end

    def self.define_method_attribute(name)
      class_eval <<-STR
        def #{name}
          @attributes[:#{name}] || @attributes["#{name}"]
        end

        def #{name}=(value)
          @attributes[:#{name}] = value
        end
      STR
    end

    def self.table_name
      singular_table_name = Mavericks.to_underscore name
      Mavericks.to_plural singular_table_name
    end

    def self.count
      self.connection.execute(<<-SQL)[0]['count']
        SELECT COUNT(*) as count FROM #{self.table_name}
      SQL
    end

    def self.to_sql(val)
      case val
      when Numeric
        val.to_s
      when String
        "'#{val}'"
      else
        raise "Can't support #{val.class} to SQL!"
      end
    end

    def self.all
      Relation.new(self).records
    end

    def self.last
      all.last
    end

    def self.find(id)
      find_by_sql("SELECT * FROM #{self.table_name} WHERE id = #{id.to_i}").first
    end

    def self.find_by_sql(sql)
      connection.execute(sql).map do |attributes|
        new(attributes)
      end
    end

    def self.where(query)
      sql_syntax = query.map do |key, val|
        "#{key.to_s} = #{self.to_sql(val)}"
      end
      Relation.new(self).where(sql_syntax)
    end
  end
end

我們將 class method 都搬到 base.rb 底下,並且把 sqlite_model.rbdata_record.rb 的程式碼整理成一起,會發現 self.establish_connection 已經可以同時支援兩種資料庫

connection_adapter.rb 我們也實作了兩種資料庫的連線方式

# mavericks/lib/active_record/connection_adapter.rb

module ActiveRecord
  module ConnectionAdapter
    class PostgreSQLAdapter
      def initialize(dbname)
        require 'pg'
        @db = PG.connect(dbname: dbname)
      end

      def execute(sql)
        @db.exec(sql)
      end

      def schema(table_name)
        self.execute("SELECT column_name FROM information_schema.columns
        WHERE table_name= '#{table_name}'").map{|m|  m["column_name"]}
      end
    end

    class SQLiteAdapter
      def initialize(dbname)
        require 'sqlite3'
        @db = SQLite3::Database.new dbname
      end

      def execute(sql)
        @db.results_as_hash = true
        @db.execute(sql)
      end

      def schema(table_name)
        @db.table_info(table_name).map{ |row| row["name"] }
      end
    end
  end
end

persistence.rb 比較單純,只有處理資料儲存部分

# mavericks/lib/active_record/persistence.rb

module ActiveRecord
  module Persistence
    def save!
      return true unless new_record?

      vals = @attributes.values.map { |value| self.class.to_sql(value) }
      self.class.connection.execute <<-SQL
        INSERT INTO #{self.class.table_name} (#{@attributes.keys.join(',')})
        VALUES (#{vals.join ","});
      SQL
      @new_record = false
    end

    def save
      self.save! rescue false
    end
  end
end

relation.rb 則保持一樣沒什麼變

# mavericks/lib/active_record/relation.rb

module ActiveRecord
  class Relation
    def initialize(klass)
      @klass = klass
      @where_values = []
    end

    def to_sql
      sql = "SELECT * FROM #{@klass.table_name}"
      if @where_values.any?
        sql += " WHERE " + @where_values.join(' AND ')
      end
      sql
    end

    def records
      @records ||= @klass.find_by_sql(to_sql)
    end

    def where(sql_syntax)
      if sql_syntax.class == Hash
        @where_values += sql_syntax.map { |key, val| "#{key.to_s} = #{@klass.to_sql(val)}" }
      else
        @where_values += [sql_syntax]
      end
      self
    end

    def first
      records.first
    end

    def each(&block)
      records.each(&block)
    end
  end
end

最後 active_record 的資料夾結構會長這樣

├── lib
│   ├── active_record
│   │   ├── base.rb
│   │   ├── connection_adapter.rb
│   │   ├── persistence.rb
│   │   └── relation.rb
│   ├── active_record.rb

接著我們要用的時候,只要直接繼承 ActiveRecord::Base 就可以了

# just_do/sqlite_test.rb

require 'active_record'

ActiveRecord::Base.establish_connection

class Task < ActiveRecord::Base
end

Task.new(title: '鐵人30', content: '每天寫一篇').save
puts Task.all.last.title
puts Task.count
Task.where(title: '鐵人30').where(content: '每天寫一篇').each do |task|
  puts task.title
end

今天大部分做得事情都是重構,程式的細節在前面幾天都有解說過,這裡就不再花費篇幅

另外因為發現程式碼越來越多,所以從第 20 天開始,會將程式碼更新在 github 上,供大家參考

https://github.com/apayu/mavericks


上一篇
[DAY 19] 復刻 Rails - ORM - 加上 where
下一篇
[DAY 21] 復刻 Rails - 用 Rails 的方式整理程式碼 Active Support
系列文
向 Rails 致敬!30天寫一個網頁框架,再拿來做一個 Todo List30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言