データストア的なクラス実装を考える


[Object <=> JSON 相互変換 on Ruby](http://qiita.com/singy/items/ceeafaf62d66b9f4a485)というエントリを見て、自分ならどう実装するか考えてみた。
「どうせならYAMLでも読めたほうがよさそう」だの「Railsなんかのモデルのようにプロパティ定義できたらよさそう」だの「バリデーション付けたら便利じゃないか？」だのわき道にそれながら実装したので、ごちゃごちゃと機能を盛ったものが出来上がった。

場当たり的にそれっぽく動くものを実装しただけなので、色々考えるべきところが抜けているし、色々突っ込みどころが満載のような気がする。
万が一実コードにこれを使う場合はその辺りを適時埋めて欲しい。

```ruby
require "json"
require "yaml"

module DataStore
  def self.included base
    base.extend ClassMethods
  end

  def initialize args = {}
    self.class.attributes.each do |attr, opts|
      # 引数の値をインスタンス変数にセット
      instance_variable_set_with_validate attr, args[attr] || args[attr.to_s]
    end
  end

  def instance_variable_set_with_validate attr, val
    instance_variable_name = "@#{attr.to_s}"
    validate = self.class.attributes[attr][:validate]
    old = instance_variable_get instance_variable_name || self.class.attributes[attr][:default]

    result = validate.nil? || validate.call(val)
    instance_variable_set instance_variable_name, result ? val : old
  end

  def to_h
    self.class.attributes.keys.map {|attr| [attr, instance_variable_get("@#{attr}")]}.to_h
  end

  def convert type
    self.class.converter[type].call self
  end

  def to_json
    convert :json
  end

  def to_yaml
    convert :yaml
  end

  module ClassMethods
    attr_reader :attributes, :default_accessor, :loader, :converter

    def attributes
      @attributes ||= superclass.instance_variable_get(:@attributes) || {}
    end

    def loader
      @loader ||= superclass.instance_variable_get(:@loader) || {
        json: JSON.method(:parse),
        yaml: YAML.method(:load)
      }
    end

    def converter
      @converter ||= superclass.instance_variable_get(:@converter) || {
        json: ->(obj){obj.to_h.to_json},
        yaml: ->(obj){obj.to_h.to_yaml}
      }
    end

    def load type, val
      new loader[type].call val
    end

    def load_json json
      self.load :json, json
    end

    def load_yaml yaml
      self.load :yaml, yaml
    end

    def add_loader type, &block
      loader[type] = block
      self.class.module_eval do |mod|
        define_method "load_#{type}" do |obj|
          self.load type, obj
        end
      end
    end

    def add_converter type, &block
      converter[type] = block
      define_method "to_#{type}" do
        self.convert type
      end
    end

    private
    def default_accessor
      @default_accessor ||= superclass.instance_variable_get(:@default_accessor) || :accessor
    end

    def attribute attr, opts = {}
      attr = attr.to_sym unless attr.is_a? Symbol
      unless attributes.keys.include? attr
        opts[:access] ||= default_accessor
        attributes[attr] = opts

        # ゲッターの追加
        attr_reader attr if [:reader, :accessor].include? opts[:access]

        # セッターの追加
        define_method "#{attr.to_s}=" do |val|
          instance_variable_set_with_validate attr, val
        end if [:writer, :accessor].include? opts[:access]
      end
    end
  end
end

class A
  include DataStore
  attribute :x, default: 0, access: :reader
  attribute :y, default: "", access: :accessor
  attribute :z, default: nil, access: :writer, validate: -> (val){
    val.is_a?(String) ? val.match(/^0[789]0-\d{4}-\d{4}$/) : false
  }
end

# キーワード引数っぽく初期化
# A#attribute で指定していないもの(:a)は読み込まない
a1 = A.new a: "foo", x: 123, y: "abc"
p a1   # => #<A:0x800c70 @x=123, @y="abc", @z=nil>
p a1.x # => 123
p a1.y # => "abc"

# JSONを読み込んで初期化
# 未指定のものは A#attribute に指定したデフォルト値が有効になる
json = {x:456, z: "090-9999-9999"}.to_json
a2 = A.load_json json
p a2   # => #<A:0x800670 @x=456, @y="", @z="090-9999-9999">
p a2.x # => 456
p a2.y # => ""

# YAMLを読み込んで初期化
# A#attribute に指定した validate を通らなければ値をセットしない
yaml = <<YAML
---
y: def
z: 000-0000-0000
YAML
a3 = A.load_yaml yaml
p a3   # => #<A:0x7fb600 @x=0, @y="def", @z=nil>
p a3.x # 0
p a3.y # def

# :z は :writer と指定しているため A#z はプライベートメソッドになり、JSON/YAMLに変換しなければ外部からは見えない

# add_loader, add_converter を使用してJSON/YAML以外の形式を読み書きするサンプル
require "csv"
A.add_loader(:csv) do |obj|
  obj = CSV.new obj unless obj.is_a? CSV
  obj.to_h
end

A.add_converter(:csv) do |obj|
  CSV.generate do |csv|
    obj.to_h.each do |key, val|
      csv << [key, val]
    end
  end
end

csv = <<CSV
x,1
y,bomb!
z,070-1234-5678
CSV
a4 = A.load_csv csv
p a4   # #<A:0x1ee7978 @x="1", @y="bomb!", @z="070-1234-5678">
p a4.x # 1
p a4.y # "bomb!"

p a4.to_csv # "x,1\ny,bomb!\nz,070-1234-5678\n"
```

~~Rubyっぽく`[attr_name]`、`[attr_name]=`で実装してもよかったが、インスパイア元に合わせて`get_[attr_name]`,`set_[attr_name]`となるように実装した。~~

「Rubyっぽくない」と言うコメントもいただき、私自身Rubyなのにget/setなのはなんとなくむずむずするので`[attr_name]`、`[attr_name]=`に修正を行った。
ただ、この形にするならば`attr_reader`等を使えばもっとシンプルに書けそうな気がしている。
が、`validate`などと言うものを入れてしまったのですぐには思いつかず、ひとまずメソッド名だけ変更した。

**2016/07/11 22:31 update**

まだセッター周りが野暮ったい気もするが、主に`attribute`のリファクタリングを行った。
ひとまずこれで完成とする。

**2016/07/12 11:55 update**

昨日完成と書いたが、ふと思いつきで任意の形式を読み書き出来る様に`add_loader`、`add_converter`というメソッドを追加した。
`load_[type]`、`to_[type]`を動的に追加しようとしたあたりでなんとも黒魔術チックなものになってしまったが、そもそもが思いつきで書き始めたものなので気にしないことにする。