Desenvolvimento

30 set, 2015

Removendo o Circular.io do Clojure

Publicidade

O Circular.io é um clone de buffer ou, pelo menos, um buffer de cerca de 5 anos. Naquela época, tudo o que um buffer fazia era deixar você agendar posts no Twitter, para serem enviados em intervalos regulares durante todo o dia. Foi criado no HN, há cerca de 3 anos, e está em funcionamento desde então. E as pessoas realmente continuam utilizando! Os desenvolvedores fizeram um ótimo trabalho com esse pequeno aplicativo, e o fato de que ele é código aberto significa que, para todos os efeitos deste artigo, podemos utilizar seu frontend e apenas instalar um novo backend.

Mas por que fazemos isso? Bem, há algumas razões:

  • Sou basicamente um racista em relação ao PHP.
  • O Clojure (ou de qualquer modo a JVM – Java Virtual Machine) é utilizada para scripts de longa duração como este, e eu acho que posso fazer um trabalho muito melhor de implementação das mesmas características com muito menos código.
  • Eu preciso de um assunto para escrever, e quero ver como este produto final se compara.

O Circular (apesar dessa coisa do PHP) é um aplicativo bem escrito usando um moderno (mesmo com cerca de 3 anos) microframework PHP, então eu pensei que essa seria uma boa comparação, e eu sei que fiz este tipo de artigo antes, mas esta é uma análise muito mais profunda, e eu estou fazendo um esforço real para evitar meu preconceito e considerar o Circular somente pelos seus méritos (e malefícios).

No momento da redação deste texto, não comecei a desenvolver ainda. Portanto, este artigo vai ser uma representação mais ou menos da forma sobre como eu trabalho, e pode ser longa e confusa. Eu queria ser honesto sobre o que é realmente importante com o desenvolvimento, mesmo quando eu tive que voltar atrás e corrigir o código anterior. Depois disso, vou criar alguma discussão sobre o assunto.

(Nota: você pode pular para o código-fonte acabado no Github).

Escrevendo o backend

Ok, então vamos criar um diretório para este projeto. Após cerca de um minuto de pensamento, cheguei ao Circulure, então: $ mkdir circulure. Eu estive trabalhando com inicialização recentemente, por isso vou começar criando um arquivo build.boot: $ gvim build.boot. Vou copiar a maioria das dependências do meu [projeto recente] [classificado], e também copiar o conteúdo at-at de redditlater, pois sei que é o que eu vou usar para agendamento.

Aqui está o conteúdo do meu boot.build:

(set-env!
 :source-paths #{"src"}
 :dependencies '[;Tasks
                 [pandeiro/boot-http "0.6.3-SNAPSHOT"]

                 ; Clojure
                 [org.clojure/clojure "1.6.0"]
                 [org.clojure/core.async "0.1.346.0-17112a-alpha"]
                 [environ "1.0.0"]
                 [clojure.jdbc/clojure.jdbc-c3p0 "0.3.2"]
                 [compojure "1.1.5"]
                 [ring "1.1.0"]
                 [org.clojure/data.json "0.2.6"]
                 [http-kit "2.1.18"]
                 [overtone/at-at "1.2.0"]
                 [com.taoensso/timbre "3.4.0"]
                 [com.novemberain/monger "2.1.0"]
])

Eu ouvi dizer que o Circular usa Mongo, então também coloquei o Monger lá. Isso é o suficiente para mim e para o $ boot repl, de modo que eu tenho algo para me conectar. Eu também vou criar um diretório src/circulure e começar a editar o arquivo core.clj.

Eu sei que vou precisar espelhar rotas para o Circular, então vamos começar com isso. A API do Circular é bem pequena e alimentada pelo Silex, por isso deve ser fácil portá-la para Compojure.

Comecei então percorrendo os arquivos index.php e preenchendo as rotas com funções de manipulador falsas:

(defroutes app-routes
  (GET "/posts" req (get-all-posts req))
  (POST "/posts" req (create-post req))
  (DELETE "/posts" req (delete-post req))
  (PUT "/posts/:id" req (update-post req))

  (POST "/times" req (update-times req))
  (POST "/upload" req (handle-upload req))
  (GET "/settings" req (get-settings req))
  (POST "/settings" req (update-settings req))
  (GET "/counter" req (get-counter req))
  )

Os manipuladores são bem parecidos, então decidi limpá-los com uma macro:

(defn json-response [data]

  {:body (json/write-str data)
   :status 200
   :headers {"Content-Type" "application/json"} })

(defmacro r [method route handler]
  `(~method ~route req# (json-response (~handler req#))))

(defroutes app-routes
  (GET / [] "HELLO")
  (r GET "/posts" get-all-posts)
  (r POST "/posts" create-post)
  (r DELETE "/posts" delete-post)
  (r PUT "/posts/:id" update-post)

  (r POST "/times" update-times)
  (r POST "/upload" handle-upload)
  (r GET "/settings" get-settings)
  (r POST "/settings" update-settings)
  (r GET "/counter" get-counter))

Ótimo! Agora podemos começar a trabalhar nas funções. Mas, me ocorreu que devemos provavelmente utilizar a autenticação mais cedo ou mais tarde.

Depois de alguma pesquisa, eu resolvi usar o Friend.

Também me lembrei do quanto eu odeio OAuth. Criei um módulo db porque eu precisava referenciá-lo como parte da função de credenciais de autenticação.

(ns circulure.db
  (:require [monger.core :as mg]
            [monger.collection :as mongo]))

(def conn (mg/connect))
(def db (mg/get-db conn "circulure"))


(defn user-from-twitter-response
  [{:keys [oauth_token
           oauth_token_secret
           user_id
           screen_name]}]
  {:twitter_access_token oauth_token
   :twitter_access_token_secret oauth_token_secret
   :twitter_user_id user_id
   :twitter_screen_name screen_name})


(defn get-user-by-twitter-user-id [user-id]
  (mongo/find-one-as-map db "users" {:twitter_user_id user-id}))


(defn put-user! [user]
  (if (:_id user)
    (do
      (mongo/update db "users" {:_id (:_id user)} user)
      user)
    (mongo/insert-and-return db "users" user)))


(defn get-or-create-account-by-user [user]
  (if-let [account (mongo/find-one-as-map db "accounts" {:users [(:_id user)]})]
    account
    (do
      (mongo/insert db "accounts" {:users [(:_id user)]})
      (get-or-create-account-by-user user))))

Então, juntei tudo ao fluxo de trabalho do script para Twitter que escrevi:

(defn credential-fn
  "Get the user by the token from mongo"
  [oauth-token-response]
  (let [user (or (db/get-user-by-twitter-user-id (:user_id  oauth-token-response))
                 (db/put-user! (db/user-from-twitter-response oauth-token-response)))
        account (db/get-or-create-account-by-user user)]
    {:identity (:twitter_user_id user) :user user :account account :roles #{::user}}))

(def friend-config
  {:workflows [(twitter-auth-workflow
                 {:consumer-key (:twitter-consumer-key env)
                  :consumer-secret (:twitter-consumer-secret env)
                  :oauth-callback-uri {:path "/oauth/twitter"
                                       :url "http://localhost:8080/oauth/twitter"}
                  :credential-fn credential-fn})]})



(def app
(-> #'app-routes 
    (wrap-user)
    (friend/authenticate friend-config)
    (wrap-keyword-params)
    (wrap-params)
    (wrap-session)
    ))

Em seguida, mudei as rotas da API para um contexto onde pudessem ser autorizadas. Tome cuidado com isso.

(defroutes api-routes

  (comment
  (r GET "/posts" get-all-posts)
  (r POST "/posts" create-post)
  (r DELETE "/posts" delete-post)
  (r PUT "/posts/:id" update-post)

  (r POST "/times" update-times)
  (r POST "/upload" handle-upload)
  (r GET "/settings" get-settings)
  (r POST "/settings" update-settings)
  (r GET "/counter" get-counter)) 
  )

(defroutes app-routes
  (GET "/" [] "HELLO")
  (context "/api/v1" req
      (friend/wrap-authorize api-routes #{::user}))
  )

Então escrevi um middleware que iria colocar os objetos de usuário e de sua respectiva conta direto no pedido:

(defn wrap-user [handler]
  (fn [{{id-obj ::friend/identity} :session :as req}]
    (handler
      (if-let [id (-> id-obj :current)]
        (assoc req
               :user (-> id-obj :authentications (get id) :user)
               :account (-> id-obj :authentications (get id) :account))
        req))))

 

E agora podemos ir direto para o objeto do usuário e começar a escrever os endpoints.

GET /posts: obter todos os locais (pelo usuário).

;; db.clj

(defn get-posts [user]
  (mongo/find-maps db "posts" {:user (:_id user)}))


;; core.clj

(defn get-all-posts [{user :user :as req}]
  (db/get-posts user))

Isso foi muito fácil. Tudo é automaticamente feito pelo json. Após alguns testes, poderemos ter de limpar as coisas para o frontend, mas neste momento tudo parece ok.

POST /posts: Criando um post.

;; db.clj
(defn put-post! [post]
  (mongo/insert db "posts" post))


;; core.clj
(defn create-post [{user :user {:keys [picture time status]} :params :as req}]
  (db/put-post! {:user (:_id user)
                 :time (if (= time "now") 0 time)
                 :status status
                 :type (if picture "post_with_media" "post")
                 :picture picture}) )

DELETE /posts/:id: a exclusão de um post é ainda mais fácil:

;; db.clj

(defn delete-post! [user post-id]
  (mongo/remove db "posts" {:user (:_id user)
                            :_id (ObjectId. post-id)}))

;; core.clj
(defn delete-post [{user :user {post-id :id} :params :as req}]
  (db/delete-post! user post-id))

PUT /post/:id: a atualização tem apenas uma função – alterar a hora de “agora” (ou seja, para publicá-la imediatamente). Isso requer mais alguns parâmetros db auxiliares:

;; db.clj
(defn put-queued-post! [post]
  (mongo/insert db "queue" post))

(defn move-to-queue [user post-id]
  (when-let [post (get-post user post-id)]
    (delete-post! user post-id)
    (put-queued-post! post)))

;; core.clj
(defn update-post [{user :user {post-id :id post-time :time} :params}]
  (if (= post-time "now")
    (db/move-to-queue user post-id)))

POST /times: O endpoint “/times” possui uma maneira de definir individualmente os tempos de mensagens:

;; db.clj
(defn update-queue-time [user post post-time]
  (mongo/update db "posts" {:_id (ObjectId. (:_id post))
                            :user (:_id user)}
                {:$set {:time post-time}}))

;; core.clj
(defn update-times [{user :user {posts :posts post-time :time} :params}]
  (doall (for [post posts]
              (db/update-queue-time user post post-time))))

GET /settings: não faz muito:

(defn get-settings [{account :account}]
  (dissoc account :users))

POST /settings apenas atualiza o endereço de e-mail para a conta de login:

;; db.clj
(defn update-account-email [account email]
  (mongo/update db "accounts" {:_id (:_id account)}
                (if email
                  {:$set {:email email}}
                  {:$unset {:email true}})))

;; core.clj
(defn update-settings [{account :account {email :email} :params}]
  (db/update-account-email account email))

GET /counter: Finalmente, o endpoint /counter apenas retorna a contagem da tabela Post:

;; db.clj
(defn post-count []
  (mongo/count db "posts"))

(defn get-counter []
  {:count (db/post-count)})

Com todos os endpoints escritos, agora vem a parte em que vamos ligá-los de verdade. Eu verifiquei o projeto Circular do Github, no subdiretório resources, e acrescentei uma rota Compojure para servir os arquivos estáticos:

(route/resources "/" {:root "Circular"})

Depois disso, era apenas uma questão de atravessar e limpar o material que não correspondesse ao esperado pelo frontend.

O Oauth teve de ser reformulado, uma vez que muitas tarefas são feitas no lado do cliente. Acabei escrevendo um fluxo de trabalho do Friend personalizado para ele:

(def consumer
  (oauth/make-consumer
    (:twitter-consumer-key env)
    (:twitter-consumer-secret env)
    "https://api.twitter.com/oauth/request_token"
    "https://api.twitter.com/oauth/access_token"
    "https://api.twitter.com/oauth/authorize"
    :hmac-sha1))

(defn circular-oauth-workflow
  "Handle OAuth responses as expected by the Circular frontend, based on params:

  ?start=1: obtain the request token and return the authorization url
  ?oauth_verifier=?: When the user returns from twitter, get the access_token
                     and authenticate the user with friend"
  [credential-fn]
  (fn [{params :params :as req}]

    (when (= (path-info req) "/api/oauth.php")
      (cond

        ; Get the request token and get the redirect url
        (:start params)
        (let [request-token (oauth/request-token
                              consumer
                              "http://localhost:8080/api/oauth.php")
              approval-url (oauth/user-approval-uri
                             consumer
                             (:oauth_token request-token))]
          {:body (json/write-str {:authurl approval-url})
           :status 200
           :headers {"Content-Type" "application/json"}
           :session (assoc (:session req) :oauth-request-token request-token)})

        ; Log the user in after getting an access token
        (:oauth_verifier params)
        (when-let [request-token (get-in req [:session :oauth-request-token])]
          (when-let [access-token (oauth/access-token
                                    consumer
                                    request-token
                                    (get-in req [:params :oauth_verifier]))]
            (make-auth (credential-fn access-token))))))))

Eu tive que voltar um pouco e garantir que os métodos que escrevi para o mongo retornassem um objeto para serialização, ou então o método json-response iria reclamar sobre não ser capaz de serializar o objeto WriteResult. Também tive que atualizar o método json-response para cuidar dos objetos ObjectId e trocar o :_id para :id, o que eu fiz usando um helper:

(declare clean-object-ids)
(defn- clean-object-id [ acc [k v]]
  (assoc acc
         (if (= k :_id) :id k)
         (cond
           (instance? ObjectId v) (str v)
           (map? v) (clean-object-ids v)
           (coll? v) (map clean-object-ids v)
           :else v)))

(defn clean-object-ids [d]
  (cond
    (map? d) (reduce clean-object-id {} d )
    (coll? d) (map clean-object-ids d)
    :else d))

(defn json-response [data]
  {:body (json/write-str (clean-object-ids data))
   :status 200
   :headers {"Content-Type" "application/json"} })

Eu também não estava recebendo a foto do perfil do usuário no Twitter. Esse parecia ser um momento tão bom quanto qualquer outro para começar a usar a API do Twitter, então adicionei a API [twitter-api “0.7.8”] às minhas dependências e acrescentamos um método para preencher o objeto de usuário no primeiro login:

(ns circulure.twitter
  (:require [twitter.oauth :refer [make-oauth-creds]]
            [twitter.api.restful :as twitter] 
            [environ.core :refer [env]]))


(defn user-creds [user]
  (make-oauth-creds
    (:twitter-consumer-key env) 
    (:twitter-consumer-secret env)
    (:twitter_access_token user)
    (:twitter_access_token_secret user)))

(defn get-user-info [user]
  (:body (twitter/users-show :oauth-creds (user-creds user) :params {:screen-name "adambard"})) 
  )


(defn fill-user-object [user]
  (let [user-info (get-user-info user)]
    (merge user
           (select-keys user-info [:profile_image_url :description]))))

E no arquivo db.clj

(defn user-from-twitter-response
  [{:keys [oauth_token
           oauth_token_secret
           user_id
           screen_name]}]
  (tw/fill-user-object ; Added
    {:twitter_access_token oauth_token
     :twitter_access_token_secret oauth_token_secret
     :twitter_user_id user_id
     :twitter_screen_name screen_name}))

Uploads de arquivos são a última coisa a ser implementada. Eu descobri rapidamente que precisaria ter uma biblioteca para esse fim. Eu resolvi utilizar o imagem-resizer,que parecia ser o melhor para o que eu queria fazer.

Essa parte me obrigou a escrever algumas funções que o PHP já tem disponíveis. Em particular, o PHP pode arquivar MD5s fora da caixa. Eu não quis usar nenhuma biblioteca adicional e, em vez disso, apenas implementei a função md5-file usando classes Java. Eu também tive que usar o middleware wrap-multipart-params para habilitar a manipulação de upload de arquivos.

Infelizmente, a manipulação de arquivos é um lugar onde o Clojure não tem ótimas instalações – provavelmente há soluções em forma de biblioteca para isso, mas, caso contrário, você estalará preso em torno dos dialetos do Java, que são um pouco… detalhados demais.

(ns circulure.core
  (:require
    ;...
    [ring.middleware.multipart-params :refer [wrap-multipart-params]]
    [image-resizer.format :refer [as-file]]
    [image-resizer.core :refer [resize]])
  (:import
    ;...
    javax.xml.bind.DatatypeConverter
    java.security.MessageDigest
    java.security.DigestInputStream))

; ...

(defn md5-file [file]
  (let [md (MessageDigest/getInstance "MD5")
        dis (DigestInputStream. (io/input-stream file) md)
        ]
    (while (not (= (.read dis) -1)))
    (-> (.digest md)
        (DatatypeConverter/printHexBinary)
        (.toUpperCase))))

(defn handle-upload [{{{filename :filename tempfile :tempfile} "userfile"} :multipart-params
                      {acct-id :_id} :account}]
  (.mkdir (io/file "resources/uploads")) ; For first run
  (.mkdir (io/file "resources/uploads/" (str acct-id)))
  (let [file-md5 (md5-file tempfile)
        dirname (str "uploads/" acct-id "/") 
        destfilename (str dirname file-md5 "-" filename)
        thumbnailfilepath (as-file (resize tempfile 100 100)
                                   (str "resources/" destfilename))
        thumbnailfilename (.getName (io/file thumbnailfilepath)) ]

    (.mkdir (io/file (str "resources/" dirname)))
    (io/copy tempfile (io/file (str "resources/" destfilename )))
    {:url (str "/" destfilename)
     :thumbnail  (str "/uploads/" acct-id "/" thumbnailfilename)}))

; ...

(defroutes api-routes
  ; ...
  (wrap-multipart-params (r POST "/upload" handle-upload)))

Finalmente, é hora de escrever a parte que realmente envia posts para o Twitter, o agendador. Essa é a parte que eu pensei que seria mais fácil no Clojure, e eu não achava que estivesse errado.

Eu decidi fazer uso do suporte para requisições assíncronas da biblioteca do Twitter, e combina-la com alguma ação core.async para fornecer uma API (externa) não baseada em callback, e garantir que o agendador poderia limpar corretamente a fila de postagem sem ser bloqueado entre os pedidos.

;; twitter.clj
(defn async-callback [c]
  (AsyncSingleCallback.
    (fn [& args] (go (>! c :ok)))
    (fn [& args] (go (>! c :failure)))
    (fn [& args] (go (>! c :exception)))))


(defn update-status [user status]
  (let [c (chan)]
    (twitter/statuses-update :oauth-creds (user-creds user)
                             :params {:status status}
                             :callbacks (async-callback c))
    c))


;; scheduler.clj
(ns circulure.scheduler
  (:require [monger.core :as mg]
            [monger.collection :as mongo]
            [circulure.twitter :as tw]
            [circulure.db :refer [conn]]
            [clojure.core.async :refer [chan go <! >!]]
            ))

(def db (mg/get-db conn "circulure"))

(defn now []
  (int (/ (System/currentTimeMillis) 1000)))


;; DB Stuff

(defn get-due-posts []
  (mongo/find-maps db "posts" {:time {:$lte (now)}}))

(defn move-to-queue [post]
  (mongo/remove db "posts" {:_id (:_id post)})
  (mongo/insert db "queue" post))

(defn get-queued []
  (mongo/find-maps db "queue"))

(defn move-to-archive [queued-post]
  (mongo/remove db "queue" {:_id (:_id queued-post)})
  (mongo/insert db "archive" queued-post) )

(defn get-user [user-id]
  (mongo/find-one-as-map db "users" {:_id user-id}))


;; 1) Move stuff to queue

(defn prepare-queued-posts []
  (doall (for [post (get-due-posts)]
           (move-to-queue post))))


;; 2) Send stuff in queue and archive it

(defn send-queued-item [item]
  (when-let [user (get-user (:user item))]
    (let [c (tw/update-status (get-user (:user item)) (:status item))]
      (go
        (let [result (<! c)]
          (move-to-archive (assoc item :api-result result)))))))

(defn send-queued-posts []
  (doall (for [item (get-queued)]
           (send-queued-item item))))


(defn run-scheduler []
  (future ; Start a new thread
    (while true
      (prepare-queued-posts)
      (send-queued-posts)
      (Thread/sleep 60000) ; 1 minute
      )))

Tudo veio junto de forma bastante direta. Ele primeiro move as mensagens que estão prontas para serem publicadas a partir do script “mensagens” para a coleção “em fila” e, em seguida, envia todas as mensagens na fila. A função tw/update-status realmente retorna um canal core.async clojure, que, em seguida, lê de forma assíncrona os dados externos para acionar o arquivamento da postagem após a publicação ser concluída.

Eu acabei não usando a função at-at; ela apenas introduz um recurso a mais quando se trata de excluir ou editar mensagens, e nós estamos apontando para a paridade com o Circular, de qualquer maneira. De um modo semelhante, não criei uma forma para repetir o envio das mensagens que falharam no envio, mas o Circular também não faz isso, por isso estou feliz em conseguir resolver isso.

Após um novo pensamento, há uma possibilidade de algo ser publicado duas vezes se for feito dessa maneira, se o processamento demorar mais de 60 segundos. O Twitter impede status idênticos, mas apenas para ser seguro:

;; scheduler.clj

(def currently-processing (atom #{}))

(defn archive-item [item]
  (move-to-archive item)
  (swap! currently-processing disj (:_id item)))

(defn send-queued-posts []
  (doall (for [item (get-queued)]
           (when-not (@currently-processing (:_id item)) ; Still a small race condition: acceptable
             (swap! currently-processing conj (:_id item))
             (send-queued-item item)))))

Agora, nada vai tentar fazer posts duplicados.

Finalmente, resta o problema do empacotamento de tudo. Felizmente, o boot torna isso muito fácil, embora eu tivesse que cuidar de algumas exclusões uberjar por mim mesmo:

;;core.clj
(ns circulure.core
  (:gen-class) ; Add gen-class
)

;...

;; Provide a main method
(defn -main []
  (run-jetty #'app {:port (Integer/parseInt (:port env "8080")) :join? false}))


;; build.boot
(deftask package []
(comp
  (aot :all true)
  (pom :project 'circulure :version "0.1.0-STANDALONE")
  (uber :exclude #{#"(?i)^META-INF/[^/]*.(MF|SF|RSA|DSA)quot;
                   #"^((?i)META-INF)/.*pom.(properties|xml)quot;
                   #"(?i)^META-INF/INDEX.LISTquot;
                   #"(?i)^META-INF/LICENSE.*quot;})
  (jar :main 'circulure.core)))

Depois de executar o boot package, o circulure-0.1.0-STANDALONE.jar aparece no diretório de destino. Após executá-lo e ir para a porta 8080, o aplicativo aparece! Tê-lo como um jar autônomo é muito legal – em teoria, qualquer um pode baixar e executá-lo (passando suas próprias credenciais do Twitter como parâmetros), mas isso depende de ter um servidor mongo em execução. Se eu tivesse pensado nisso antes, eu teria usado algum tipo de armazenamento de dados incorporados. Ah, ok.

Limpeza

Depois de terminar de criar todas as funcionalidades, tive que limpar algumas coisas. A maior parte delas é chata – a principal mudança a tomar foi nas funções de autenticação do arquivo core.clj e movê-lo para o arquivo auth.clj que exporta apenas uma única função, wrap-auth, que é usada para empacotar o manipulador no lugar do Friend e dos middlewares de usuários que estavam lá antes.

Eu criei uma função auxiliar para coagir um objeto para mongodb ObjectId; em seguida, passei por db.clj e fiz com que todas as consultas envolvendo um valor id estivessem envoltas no auxiliar.

Discussão

Diferenças de design

Alguns aspectos do backend que escrevi diferem da implementação de referência:

  • Lojas com Circular armazenam todo o objeto do usuário no post; Circulure apenas armazena o ID. Isso significa que, potencialmente, mais pesquisas serão feitas quando o usuário for necessário, mas a única vez em que esse será o caso é durante o ciclo de agendamento; o ID será o suficiente para outros fins. Na verdade, o Circular tem que retirar manualmente o objeto de usuário (ID de salvar) a partir das respostas da API, a fim de não revelar quaisquer segredos da API.
  • A fila de publicação do Circulure é executada no mesmo processo do servidor web, apenas em um segmento diferente. Isso torna o compartilhamento de código um pouco mais fácil, mas poderia ser um problema de desempenho sob muito carregamento.
  • Eu fiz questão de separar as operações de banco de dados em suas próprias funções. Isso permite que o banco de dados possa ter trocado, se necessário, com um mínimo de problemas (embora a semântica do campo _id ainda precise ser contabilizada no código do aplicativo).

Comparação de código

É difícil comparar as duas bases de código diretamente, então eu só vou abordar o que eu considero amostras representativas dos pontos fortes e fracos de cada aplicação. Mas, primeiro, aqui é a contagem de linha, porque eu sei que alguém vai perguntar.

Clojure

Nome do arquivo Número da linha
core.clj 182
auth.clj 80
db.clj 103
scheduler.clj 63
twitter.clj 42
Total 470

PHP

Nome do arquivo Linha
index.php 322
oauth.php 242
ParallelTasks.php 193
Total 753

No entanto, os arquivos PHP são espaçados mais generosamente e contêm comentários extensos, mas não me incomodei com isso; na realidade, provavelmente isso funcione em algum momento.

Onde foi que o código ganhou?

Dado que o Clojure não é projetado especificamente para ser uma linguagem para a construção de websites, tive que implementar algumas das coisas que tenho feitas em PHP, como arquivos md5.

Vamos comparar o código do upload de arquivos:

$protected->post('/upload', function (Request $request) use ($app) {
    $file = $request->files->get('userfile');
    if ($file->isValid()) {
        $extension = $file->guessExtension();
        // Use MD5 to prevent collision between different pictures:
        $md5 = md5_file($file->getRealPath());

        $filename      = 'uploads/' . $app['account']['id'] . '/' . $md5 . '.' . $extension;
        $thumbnailname = 'uploads/' . $app['account']['id'] . '/' . $md5 . '.100x100' . '.' . $extension;

        $file = $file->move(__DIR__.'/../uploads/'.$app['account']['id'], $md5.'.'.$extension);

        // Create thumbnail:
        $simpleResize = new SimpleResize($file->getRealPath());
        $simpleResize->resizeImage(100, 100, 'crop');
        $simpleResize->saveImage(__DIR__.'/../'.$thumbnailname, 100);

        return $app->json(array('url' => APP_URL.$filename, 'thumbnail' => APP_URL.$thumbnailname));
    }
});

 

(defn md5-file [f]
  (let [md (MessageDigest/getInstance "MD5")
        dis (DigestInputStream. (io/input-stream f) md)
        ]
    (while (not (= (.read dis) -1)))
    (-> (.digest md)
        (DatatypeConverter/printHexBinary)
        (.toUpperCase))))

;; POST /upload
(defn handle-upload [{{{filename :filename tempfile :tempfile} "userfile"} :multipart-params
                      {acct-id :_id} :account}]
  (.mkdir (io/file "resources/uploads")) ; For first run
  (.mkdir (io/file "resources/uploads/" (str acct-id)))
  (let [file-md5      (md5-file tempfile)
        dirname       (str "uploads/" acct-id "/") 
        destfilename  (str dirname file-md5 "-" filename)
        thumbnailpath (as-file (resize tempfile 100 100)
                               (str "resources/" destfilename))
        thumbnailname (.getName (io/file thumbnailfilepath)) ]

    (io/copy tempfile (io/file (str "resources/" destfilename )))
    {:url (str "/" destfilename)
     :thumbnail  (str "/uploads/" acct-id "/" thumbnailfilename)}))

Não há dúvidas de que o código PHP está muito à frente aqui, em concisão e clareza.

Em outros casos, o Clojure foi muitas vezes capaz de expressar operações com bem menos código – embora talvez isso tenha acontecido porque eu tinha a vantagem de começar do zero e com uma especificação bem definida:

$protected->post('/posts', function (Request $request) use ($app) {

    $post = $app['data'];

    // Check that this account really manages this user
    if (!array_key_exists($post['user'], $app['account']['users'])) {
        return new Response('Unauthorized', 401);
    }

    // Add user information:
    $m = new Mongo();
    $user = $m->circular->users->findOne(array('_id' => new MongoId($post['user'])));
    $post['user'] = $user;

    // Add Twitter request info:
    if (isset($post['picture'])) {
        $post['type'] = 'post_with_media';
    }
    else {
        $post['type'] = 'post';
    }

    // Nest status into `params`:
    $post['params'] = array('status' => $post['status']);
    unset($post['status']);
    // XXX: Apparently Backbone has poor support for nested attributes
    // @see http://stackoverflow.com/questions/6351271/backbone-js-get-and-set-nested-object-attribute


    $m = new Mongo();

    if (isset($post['time']) && $post['time'] == "now") {
        // If explicitly requested, send it right now through `queue`:
        $m->circular->queue->insert($post);
    }
    else {
        $m->circular->posts->insert($post);
    }

    // MongoId are assumed to be unique accross collections
    // @see http://stackoverflow.com/questions/5303869/mongodb-are-mongoids-unique-across-collections

    return $app->json(array('id' => (string) $post['_id']));
});

 

Vs

;; core.clj
(defn create-post [{user :user {:keys [picture time status]} :params :as req}]
  (db/put-post! {:user (:_id user)
                 :time (if (= time "now") 0 time)
                 :status status
                 :type (if picture "post_with_media" "post")
                 :picture picture}))


;; db.clj
(defn put-post! [post]
  (if (:_id post)
    (do (mongo/update db "posts" {:_id (object-id (:_id post))} post)
        post)
    (mongo/insert-and-return db "posts" post)))

Se isso é uma vitória da linguagem ou apenas a implementação, serve como argumento.

Fora isso, os dois são bastante equivalentes. O Silex é de fato um framework muito bom para se trabalhar – os flexíveis manipuladores ->before e ->after em conjuntos de rotas comparáveis ao fornecimento de middleware Ring, que é um dos meus recursos favoritos do Ring.

Desempenho

Se alguém se preocupa com o benchmark dos dois, sinta-se livre para comentar sobre isso. O Silex possui uma marca indicando que possui uma penalidade de desempenho, mas, novamente, há muitos projetos que podem acelerar o PHP notavelmente.

Implantação

Normalmente, o PHP tem uma vantagem sobre a implantação, o que o amigável recurso de FTP arraste-e-solte geralmente permite. Mas, nesse caso, ambas as implementações requerem acesso suficiente a um servidor para configurar um trabalho de longa duração (o daemon no caso do PHP, e executar o arquivo jar no caso do Clojure), e eu não acho que nenhum desses seja mais difícil do que o outro.

Ainda acho que é muito legal simplesmente executar um arquivo jar e ter a coisa toda rodando.

Preocupações não-técnicas

A objeção mais citada para o Clojure é a sintaxe; pilhas de documentos foram escritas sobre isso, então não vou entrar em muitos detalhes na defesa ou acordo dessa noção. Posso dizer que, se não for controlado, o código Clojure (ou qualquer linguagem lisp, na verdade) pode descender em um ninho denso de expressões, especialmente tendo em conta o estilo de codificação iterativo que emerge da integração com o editor REPL. Eu fiz o meu melhor para escrever um código claro, mas dizer que o código é “claro” é um termo relativo que eu não sou capaz de julgar, mesmo já estando familiarizado com o Clojure. Então, novamente, para parafrasear o rei Ricardo, só porque você não fala alemão, não significa que o alemão é incompreensível.

Claro, eu não disse nada sobre o que pode acontecer no caso de o PHP ser utilizado por mãos descuidadas, e eu não acho que até mesmo o evangelista mais entusiasta de PHP discordaria dos horrores que poderiam surgir.

Então é isso por hoje, espero que tenham gostado. Você pode dar uma olhada no código-fonte completo no GitHub, se quiser.

***

Adam Bard faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: http://adambard.com/blog/ripping-off-circular/