SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

Ruby on Rails + Curl(AD)

Curl+JRuby+Google App EngineでTwitter風アプリを作る
~GAE編~

最終回

  • このエントリーをはてなブックマークに追加

 前回作成したローカル環境のJRuby on RailsをGAE上に持っていきます。クラウド上に作成したアプリとCurlクライアントをつなぐことで、Ruby on Rails + Curlを実現します。

  • このエントリーをはてなブックマークに追加

はじめに

 今回は、前回作ったTwitter風アプリをGoogle App Engineで動くように改造していきます。

 Google App Engineは単なるサーバ環境の提供ではなくデータ管理等も含む独自環境です。そのため、Ruby on Railsのアプリケーションを動かす上で大きな問題点になってくるのは、データ管理機構がRDBではないことです。

 GAEのデータ管理機構「Datasotre(BigTable)」は基本的には Key-Value ストアです。一応、RDBのようにテーブルや行列という概念を持っており、RDB同様に行単位でデータの読み書きを行え、上位ライブラリではSQL風言語もサポートしています。しかし、基本はKey-Value ストアであり検索で指定できる条件は限られています。リレーション(join)等もありません。

 Ruby on Railsの高生産性を支えている大きな要素は、RDBを簡単に扱えるO/RマッパーActiveRecordと、それに連携したテンプレートActionViewやコントローラーActionControllerのコンビネーションです。しかし、ActiveRecordは完全にRDBに依存した設計のため、Datasotreを扱う事はできません。従って現時点では、GAE上で動くRuby on Railsアプリを作るのは簡単ではありません。

 そういった点の回避策も含めて、GAE上でRailsアプリを動かし、Curlクライアントと連携していく手順を見ていきましょう。

GAE環境のインストール

 今回使うrails_on_gaeですが、筆者のWindows環境では正しく動作しませんでした。今回はMac OS XやLinux等のUNIX系OSを使う前提で、環境構築などを説明していきます。

Google App Engine SDK for Javaのインストール

 Downloads - Google App Engine - Google CodeからGoogle App Engine SDK for Java(2009年9月30日時点は appengine-java-sdk-1.2.5.zip )をダウンロードし、適当な場所(例えば /usr/local/appengine-java-sdk-1.2.5 )に展開します。また、展開した中の binディレクトリをPATHに追加します。

WAR作成ツールのインストール

 gemでインストールします。

% sudo  jruby -S gem install warbler  --no-ri --no-rdoc

GAE/J用のRailsアプリを作る

 GAE/J上で動かすには、Ruby on Rails自体やGAEのAPIをアクセスするためのライブラリが必要になります。これらの設定を行ってくれるrails_on_gae プラグインが、technohippy さんによりtechnohippy's rails_on_gae at master - GitHubにて公開されてます。これを使うと簡単にGAEで動くJRuby on Rails環境が簡単につくれます。

 ここでは jtinytterとういプロジェクトを作成します(この作業は時間がかかる場合があります)。

% jruby -S rails jtinytter
% cd jtinytter
% jruby -S script/plugin install git://github.com/technohippy/rails_on_gae.git
% jruby -S rake gae:init
% cp /usr/local/appengine-java-sdk-1.2.5/lib/user/appengine-api-1.0-sdk-1.2.5.jar  lib

GAE向けにソースを書き換える

 最初に説明したようにGAEではActiveRecordが使えませんので、 GAEのDatastoreを扱えるモジュールを使ってモデルを書き換えます。今回はモデルのみの変更で済むように工夫しています。従って、コントローラー、テンプレート等は前回作ったものからコピーしてください。

 Datastoreの操作については、JRubyから直接DatastoreのJava APIを呼び出す事もできますが、今回は上記のインストールで入手できる「Bumble」というモジュールを使います。このモジュールはOla Biniさんの作ったモジュールで、Datastore の機能をRubyから簡単に使えるAPIと、Rails風の関連機能(has_many、belongs_to)を追加したシンプルなものです。ソースコードはここで見れます

 書き換えの内容については次ページ以降で解説していきます。

サンプルソースについて

 こちらよりサンプルソースをダウンロードできます。なお、サンプルソース実行にあたってはライセンスファイル「curl-license-5.dat」が必要になります。Curl IDEなどに含まれているので、コピーして使用してください。

前回サンプルソースのお詫びと訂正

 前回のサンプルソースですが、データベースの設定ファイルが間違っていました。お詫びして訂正します。

 JRubyで動かすにはconfig/database.yml ファイルには「jdbcsqlite3」を指定する必要がありました。config/database.ymlファイルの4行目を以下のように修正してください(※現在公開されているサンプルソースは修正済みです)。

#adapter: sqlite3
adapter: jdbcsqlite3

GAE用の書き換え 1

 それでは、前回ローカル環境で構築したアプリをGAEで動作させるために、どのように書き換えたのか見ていくことにしましょう。

Friendshipモデルの書き換え

 ActiveRecordを使ったモデルはActiveRecord::Baseを継承してしましたが、BumbleではBumbleをinclude(Mixin)するだけです。ActiveRecordFeaturesForBumbleというActiveRecordと互換を取るために作成したモジュールもinclude(Mixin)しています。詳しくは後述します。

 ActiveRecordではカラム情報(属性情報)はRDBから自動的に取得していますが、Bumbleではdsでカラム名(属性名)を定義します。belongs_toの定義は多少違いますが、ActiveRecordと似た形になっています。

 条件に合うレコードすべて取得するメソッドはBumbleでも all ですが、一致検索条件を hash で指定します。また Datastoreの制限で <> (一致しない) は指定できないので、self.friendsメソッドでは自分自身のレコードを除くためにRuby配列の reject!メソッドを使っています。

 次の self. not_friend_usersメソッドでは、Datastoreには in 演算子の機能がないので、やはりRubyのイテレータを使って全ユーザーから友人のレコードを削除し、友人以外の一覧を作っています。self.create_selfメソッドは同じです。

ActiveRecord版
class Friendship < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, :class_name => "User", :foreign_key => "friend_id"
  
  def self.friends(my_id)
    Friendship.all(:conditions => ["user_id = ? and friend_id <> ?", my_id, my_id])
  end

  def self.not_friend_users(my_id)
    User.all(:conditions => ["id not in (?)", Friendship.find_all_by_user_id(my_id).map(&:friend_id)])
  end
  
  def self.create_self(my_id)
    Friendship.new(:user_id => my_id, :friend_id => my_id).save!
  end
end
Bumble版
class Friendship
  include Bumble
  include ActiveRecordFeaturesForBumble
  
  ds :user_id, :friend_id
  belongs_to :user, User
  belongs_to :friend, User
  
  def self.friends(my_id)
    Friendship.all(:user_id => my_id).reject {|f| f.friend_id == my_id}
  end

  def self.not_friend_users(my_id)
    not_friends = User.all
    Friendship.all(:user_id => my_id).each do |friend|
       not_friends.reject! {|user| user.id == friend.friend_id}
    end
    not_friends
  end

  def self.friend_user_ids(my_id)
    Friendship.all(:user_id => my_id).map{|f| f.friend_id}
  end
  
  def self.create_self(my_id)
    Friendship.new(:user_id => my_id, :friend_id => my_id).save!
  end
  
end

Userモデル

 Userモデルでの変更は、Friendshipモデルで説明したのとほぼ同じです。ただし、画像が格納されるディレクトリが変更になっているのはJavaのデプロイ環境に合わせ、app/以下のプログラムが public/WEB-INFディレクトリ下に置かれるからです。

ActiveRecord 版
class User < ActiveRecord::Base
  has_many :friendships
  has_many :statuses
  
  def self.authenticate(screen_name, password)
    user = self.find_by_screen_name(screen_name)
    (user && user.password == password) ? user.id : nil
  end

  IMAGES_DIR = "./public/images/"
  PROFILE_IMAGES_DIR = "profile_images"
  
  def self.profile_image_selector
    Dir.glob(IMAGES_DIR + PROFILE_IMAGES_DIR + "/*").map {|path| path.sub(IMAGES_DIR, '')}
  end
  
  after_create {|rec| Friendship.create_self(rec.id)}

end
Bumble 版
class User
  include Bumble
  include ActiveRecordFeaturesForBumble

  ds :screen_name, :password, :profile_image_path
  has_many :friendships, Friendship, :user_id
  has_many :statuses, Status, :user_id
  
  def self.authenticate(screen_name, password)
    user = User.first(:screen_name => screen_name)
    (user && user.password == password) ? user.id : nil
  end

  IMAGES_DIR = "../images/"
  PROFILE_IMAGES_DIR = "profile_images"
  
  def self.profile_image_selector
    Dir.glob(IMAGES_DIR + PROFILE_IMAGES_DIR + "/*").map {|path| path.sub(IMAGES_DIR, '')}
  end
  
  after_create {|rec| Friendship.create_self(rec.id)}
end

Statusモデル

 Datastoreにはjoinの機能はありませんので、友人毎の発言を取得しRubyの機能で合成、ソートしています。JSON用のHashデータを作成する部分も、Datastoreから取得したデータをHashに変換する際に便利な attributsメソッドがないため、やや煩雑なコードになっています。

ActiveRecord版
class Status < ActiveRecord::Base
  belongs_to :user
  
  def self.all_frends(my_id)
    Status.all(:conditions => ["friendships.user_id = ?", my_id],
                    :joins => "JOIN friendships ON statuses.user_id = friendships.friend_id",
                    :order => "created_at DESC")
  end
  
  def api_data(image_url)
    status_attrs = self.attributes
    user_attrs = User.find(self.user_id, :select => "id,screen_name").attributes
    user_attrs["profile_image_url"] = image_url.call(self.user.profile_image_path)
    status_attrs["user"] = user_attrs
    status_attrs
  end
end
Bumble版
class Status
  include Bumble
  include ActiveRecordFeaturesForBumble
  
  ds :text, :user_id, :created_at
  belongs_to :user, User
  
  def self.all_frends(my_id)
    statuses = []
    Friendship.all(:user_id => my_id).each do |friend|
      statuses += Status.all(:user_id => friend.friend_id)
    end
    statuses.sort {|a, b| b.created_at <=> a.created_at}
  end
  
  def api_data(image_url)
    status_attrs = Hash["text", self.text, "user_id", self.user_id, "created_at", self.created_at]
    user = User.find(self.user_id)
    user_attrs = Hash["id", user.id, "screen_name", user.screen_name]
    user_attrs["profile_image_url"] = image_url.call(self.user.profile_image_path)
    
    status_attrs["user"] = user_attrs
    status_attrs
  end
end

ActiveRecordFeaturesForBumbleモジュール

 Bumbleの足りない機能を追加するActiveRecordFeaturesForBumbleですが、かなり難易度の高いRubyモジュールのため、詳細に説明すると本連載の範囲を超えてしまいますので、内容を簡単に説明します。

 まずモジュールの構成ですが、クラスに追加する インスタンスメソッドを InstanceMethodsモジュールに定義、クラスメソッドをClassMethods モジュールに定義し、include時に呼び出される self.includedメソッドを使ってincludeしたクラスに各メソッドの追加を行っています。このようなスタイルはRailsの内部やプラグインでよく使われています。

 id、update_attributes、save、destroyメソッドはBumbleには無いですが、コントローラー等で使われているメソッドの追加です。また、new_record?、errors、to_paramメソッドは明示的に呼び出されていませんがテンプレート用のメソッド等から呼び出されています。これらのメソッドはActiveRecordの全仕様を実装したものではなく、このアプリで必要な部分のみの実装です。例えば、入力値等の検証(Validation)結果を戻すerrorsはいつでもエラー無しを戻します。

 さて、find、allはBumbleで定義されているメソッドの置き換えです。最後の方にあるalias first find; alias all_origin all;でBumbleに定義されたfindメソッドをfirst、allメソッドをall_originという名前を付けて保存し、find、allを再定義しています。Rubyはオープンクラスで既存クラスのメソッドを後から追加・変更できます。find、allのおもな変更点は。新規に作成しまだDBに保存してないインスタンスと、DBから読み出したインスタンスを区別するための目印を付ける事です、この目印をチェックするのがnew_record?メソッドです。

 saveメソッドではややトリッキーな事を行っています。まずafter_createで定義されたDatastoreレコードの作成後に実行するコードの実行する部分と、カラム名(=インスタンス変数名)により特殊な処理を行う_instance_conversionです。

 関連を示すID番号等はformから文字列でポストされてきますが、Datastore内には数値として格納されています。この変換はActiveRecord内で自動的に行われますがBumbleでは行われません。そこで_instance_conversionは_idで終わるカラムは数値だという前提で、文字列を数値に変換しています。また、created_atカラムには現在の時間を代入しています。

module ActiveRecordFeaturesForBumble
  module InstanceMethods
    def id
      key
    end

    def update_attributes(attrs)
      attrs.each do |k, v|
        send "#{k}=", v
      end
      save
    end

    def save
      _instance_conversion
      begin
        new_recod = new_record?
        save!
        _after_create.call(self)   if respond_to?(:_after_create) && new_recod
        true
      rescue
        RAILS_DEFAULT_LOGGER.debug("+++ save error :#{$!}")
        false
      end
    end
    
    def destroy
      delete!
    end

    def new_record?
      !instance_variable_get(:@_finded_record)
    end

    def errors
      ret = []
      def ret.count
        0
      end
      ret 
    end
    
    def to_param
      key.to_s
    end

    private

    def _instance_conversion
      methods = self.class.instance_methods(false)
      methods.each do |method|
        if method =~ /_id=$/
          send(method, send(method[0..-2]).to_i)
        elsif method == "created_at="
          self.created_at = Time.now
        end
      end
    end
  end

  module ClassMethods
    
    def find(id)
      obj = self.get(id)
      obj.instance_variable_set(:@_finded_record, true)
      obj
    end
    
    def all(opt = {})
      list = all_origin(opt)
      list.each {|e| e.instance_variable_set(:@_finded_record, true)}
      list
    end
        
    def after_create(&block)
      cattr_accessor :_after_create
      self._after_create = block
    end
  end
  
  def self.included(base)
    base.send :include, InstanceMethods
    base.class_eval("class << self; alias first find; alias all_origin all; end")
    base.send :extend,  ClassMethods
  end
end

GAE用の書き換え 2

その他

 (1)環境設定のconfig/enviroment.rbファイルですがrails_on_gaeプラグインが作り出した内容を一部変更しています。

require 'rubygems'
require 'lib/require_fix'
require 'lib/rake_fix'
#require 'lib/actionmailer-2.3.2.jar'
require 'lib/actionpack-2.3.2.jar'
#require 'lib/activerecord-2.3.2.jar'
#require 'lib/activeresource-2.3.2.jar'
require 'lib/activesupport-2.3.2.jar'
require 'lib/rails-2.3.2.jar'
require 'lib/jruby-openssl-0.5.1.jar'
require 'lib/bumble'                               # <--- bumble_appengine_jruby からオリジナルのbumbleに変更
require 'lib/beeu'
require 'lib/active_record_features_for_bumble'    # <--- 追加

 (2)コントローラーの共通部分app/controllers/application_controller.rbですが、CurlからのアクセスにはCSRF対策の値がポストされないので、protect_from_forgeryをコメントアウトします。

 (3)デフォルトのindex.htmlファイルがあると入り口ページが表示されませんのでpublic/index.htmlを削除します。

日本語文字化け対処

 現在のDatastoreライブラリですが、日本語が文字化けするので以下のパッチをconfig/initializers/datastore_patch.rbに書きます。このパッチはmilk1000ccさんが作られたものです。

module AppEngine
  module Datastore
    def Datastore.ruby_to_java(value)  # :nodoc:
      if SPECIAL_RUBY_TYPES.include? value.class
        value.to_java
      else
        case value
        when Fixnum
          java.lang.Long.new(value)
        when Float
          java.lang.Double.new(value)
        when String
          #value.to_java_string
          java.lang.String.new(value) # Thanks http://d.hatena.ne.jp/milk1000cc/20090802/1249218370
        else
          value
        end
      end
    end
  end
end

OpenSSLの制限

 GAEでは一部暗号化の関数が制限されていて、セッションに使う Cookieの改ざん検出の部分でエラーになってしまいます。そこでメッセージダイジェスト作成関数をconfig/initializers /openssl_patch.rb ファイルで変更しています。

# 実アプリには使わないで下さい
require 'digest/sha1'

module ActiveSupport
  class MessageVerifier
    private
      def generate_digest(data)
        Digest::SHA1.hexdigest(data)
      end
  end
end

ローカルでの実行とデバック

 GAE用のアプリはPC上で開発用サーバを使って実行できます。ただし、JavaアプリのデプロイのようにWARファイルの作成が必要になります。また実行中にソースコードを変更しても、変更は反映されません。

手順

% jruby -S warble war        # WARファイルの作成
% dev_appserver.sh tmp/war   # 開発用サーバーの起動

 アプリは http://localhost:8080/ で確認できます。開発時にDatastoreの内容を確認した場合は、http://localhost:8080/_ah/admin にアクセスする事で、下の画面のように Datastoreの内容を見たりレコードを削除したりできます。

GAEへのデプロイ

 ローカルで動作が確認できたら、実際のGAE環境にデプロイしてみましょう。 GAEを使うには、アプリケーションID(Application Identifier)を取得する必要があります。この手順はCodeZine: Google App Engine for Javaを使ってみよう!(4ページ)に詳しく書かれています。

 デプロイする際には appengine-web.xml ファイルの <application> ~ </application> の ~ の部分にアプリケーションIDを書きます。デプロイは次のコマンドで行います。途中でアプリケーションID取得で使った e-mail、パスワードでの認証が行われます。

% appcfg.sh update tmp/war
Reading application configuration data...
   ...
   ...
Email:                               # <---     e-mailを入力
Password for ~~~:     # <----  パスワードを入力
   ...
   ...
Update completed successfully.
Success.
Cleaning up temporary files...

 デプロイが完了したら、 http://アプリケーションID.appspot.com/ をアクセスするとアプリが使えます。ただし、最初のアクセス時にはページが表示されるまでにかなり時間がかかりま す。

CurlクライアントからのGAEアクセス

 ここで使用しているCurlの開発用ライセンスは、localhostから読み込んだコードしか実行できないためGAEサーバのCurlページは実行できません。しかし開発用ライセンスで動くCurlアプリからインタネット上のサービスにはアクセスできますので、今回のサーバ側ソフトをGAEサーバをアクセスするように変更し、PC(localhost)上で動かせばGAEサーバ上のTwitter風サービスをCurlから使う事が出来ます。

 やり方は app/views/statuses/start.html.erb ファイルの6行目を以下のよう変更します。

app/views/statuses/start.html.erb
|| {let server_url = "http://<%= request.host_with_port() %>/"} 
{let server_url = "http://アプリケーションID.appspot.com/"}

 そしてWARファイルを再作成し、開発用サーバを立ち上げ http://localhost:8080/statuses/start をアクセスするとCurlクライアントから GAEサーバにアクセスできます。

Curl製Twitterクライアントからのつぶやきポスト

 さて、GAEサーバ上のアプリとCurlクライアントはうまく接続できたでしょうか。GAEというクラウド上にアプリ(サーバ)を立てることで、あとは専用のCurlクライアントさえあれば、誰でも接続できるようになります。しかも自分専用、あるいは少人数で使う分にはデータ量も少ないため、無料で使い続けることができます。

 せっかくですのでCurlクライアントを機能拡張してみましょう。前回作った、Curl製Twitterクライアントにはつぶやきをポストする機能がありませんでしたので、追加してみます。

 コードは次のようになります。フォームを作って入力された値をサーバにポストするコードは連載第2回 リッチクライアントCRUDアプリを作成するで作ったものを流用しました。HttpFormを使う事で入力したつぶやきを簡単にサーバにポストできます。

{curl 7.0 applet}
{curl-file-attributes character-encoding = "utf8"}

{import * from CURL.IO.JSON}

{let server_url = "http://<%= request.host_with_port() %>/"}
{let json_data_path = "statuses/friends_timeline.json"}
{let login = "neko"}
{let password = "pass"}
{set-http-authentication {url server_url}, login, password}


{let twitters: RecordSet =
    {RecordSet
        {RecordFields
            {RecordField "screen_name", caption = "", domain = String},
            {RecordField "text", caption = "", domain = String},
            {RecordField "icon", caption = "", domain = String}
        }
    }
}

{define-proc public {add-rec
                        text: String,
                        screen_name: String,
                        profile_image_url: String
                    }: void
    let new-rec: Record = {twitters.new-record}
    {new-rec.set "text", text}
    {new-rec.set "screen_name", screen_name}
    {new-rec.set "icon", profile_image_url}
    {twitters.append new-rec}
}


{define-proc public {get-time-line}:void
    {let twittersJsonValue: JsonValue =
        {JsonValue-parse
            {url server_url & json_data_path}
        }
    }
    {twitters.delete-all}
    {for twitter: JsonObject in twittersJsonValue do
        let found?: bool = false
        let text: String = {String}
        let screen_name: String = {String}
        let profile_image_url: String = {String}

        let user: JsonObject = {twitter.get "user"}
        set (text, found?) = {twitter.get-if-exists "text"}
        set (screen_name, found?) = {user.get-if-exists "screen_name"}
        set (profile_image_url, found?) = {user.get-if-exists "profile_image_url"}
        {if found? then
            {add-rec text, screen_name, profile_image_url}
        }
    }
}

{define-class public open IconCell
  {inherits StandardRecordGridCell}
  
  field private icon:Frame = 
      {Frame width={make-elastic}, height={make-elastic}}

  {constructor public {default}
    {construct-super}
    set self.height = 48px
    {self.add-internal self.icon}
    set self.cells-take-focus? = self.can-update?
  }
  
  {method public open {refresh-data}:void
    let (data:String, valid?:bool) = {self.get-formatted-data}
    {try
        set self.icon.background = {url data}
     catch e:Exception do
        {unset self.icon.background}
        {output "icon get error " & e.message & " " & data }        
    }
  }
}

{let form_text:TextField = {TextField name = "status[text]", width = 17.5cm, height = 0.8cm}}
{let post_form:HttpForm =
    {HttpForm
        {url server_url & "statuses.json"},
        method = HttpRequestMethod.post,
        encoding = HttpFormData.urlencoded-mime-type,
        default-character-encoding = "utf8",
        
        {spaced-vbox
            width = 20cm,
            margin = 5pt,
            "いまなにしてる?",
            {HBox
                spacing = 0.3cm,
                form_text,
                {CommandButton
                    horigin="right",
                    label = "つぶやく",
                    {on Action do
                        {try
                            {with-open-streams response:#TextInputStream =
                                {post_form.submit-open
                                    character-encoding = {get-character-encoding-by-name "utf8"}}
                            do
                                {set form_text.value = ""}
                            }
                        catch e:Exception do
                            {popup-message "Error :" & e.message}
                        }
                        {get-time-line}
                    }
                }   
            }
        }
    }
}

{let time_line: RecordGrid =
    {RecordGrid
        width=20cm,
        height=12.7cm,
        editable? = false,
        record-source = twitters,
        {RecordGridColumn  "icon", width = 48px, 
                column-resizable? = false, cell-spec = IconCell},
        {RecordGridColumn  width = 3cm, "screen_name"},
        {RecordGridColumn  width = 15cm, "text"}
    }
}

{get-time-line}
{value
    {spaced-vbox
        post_form,
        time_line
    }
}

まとめ

 本連載ではRuby on RailsとCurlをかんたんに接続できることについて、実際のコードを交えながら解説してきました。第3回ではRailsの特徴であるScaffoldを使ってCurlソースを生成する方法も紹介しました。

 連載後半では、今話題のクラウド(GAE)上のサーバにRailsを配置し、クライアントCurlと連携する方法について説明しました。開発ライセンスのためやや煩雑な部分もありましたが、正規ライセンスであればこういった問題も解消され、手軽にクラウドを活用できることがご理解いただけたかと思います。将来的には自社サーバではなくクラウドを軸にしたクライアント配布体制の構築も視野に入ってきます。

 業務の効率化を高めるためにもユーザーインターフェースの重要性は増すばかりです。クライアントにCurlを使い、環境構築がすぐにできるRailsをサーバーサイドに使うことで、こういった要望にも応えられるシステムを手軽に構築することも可能です。Curl + Ruby on Rails、ぜひ試してみてください。

この記事は参考になりましたか?

  • このエントリーをはてなブックマークに追加

【AD】本記事の内容は記事掲載開始時点のものです 企画・制作 株式会社翔泳社

この記事は参考になりましたか?

この記事をシェア

  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/4434 2009/09/30 14:00

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング