乐者为王

Do one thing, and do it well.

你能真正地掌握多少编程技术?

英文原文:http://thecodist.com/article/how-many-programming-technologies-can-you-really-master

我总是看见公司或者招聘人员广告他们正在寻找的人:“有从零开始开发iOS和Android应用的丰富经验,必须掌握现代移动和Web技术,包括Java、HTML5、CSS3、JavaScript、JSON和AJAX”。

没有这样的人。你可以掌握一门技术但在其它方面平平;你可以掌握一门技术然后转向另一门技术但是会忘记很多先前的技术;你可以简单地欺骗足够多的人让他们认为你能做到,然后期望你恰巧能够搞定它。

在今天,任何主要领域的编程都是高度复杂的,不断变化的,并且通常是带着很大时间压力完成的。所有这些都不允许你投入大量非编程时间去学习即使是最新的变化,更不用说从零开始掌握一切。你只能通过做真实的项目了解新环境,有多少人能够在同一时间同时编写所有大型的本地Android、本地iOS和响应式Web客户端呢?

在我作为程序员的34年里,我很少工作在超过一个主要领域。我的第一份工作是在一台supermini上,然后Apple上的6502汇编和在一台PC上的Pascal,我的两个创业公司都是用C为Mac开发,我更多地为其他人(包括Apple)工作,用C为Mac开发,一点点C++,接着从Objective-C/WebObjects转换到用Java开发Web客户端和服务端(尽管两者都很少),一些JavaScript,然后在Mac和Windows上进行C++游戏编程,最后是Objective-C和iOS。每次转换都是匆匆忙忙地大量学习,接着是年复一年的掌握所有新的东西。

如果由于某种原因,有人确实能做Android和iOS两者——更不用说Web——以一个真正的大师水平,他们应该能赚比大多数公司愿意支付的更多的钱。公司想要的是雇几个能做所有事情的人,并且以他们能够得到的最低的工资水平。然而我无法理解有人能够同时在这么多事情上成为一个专家,以及他们如何能够用多种技术编写多个应用并且坚持下去。我认识一些极其聪明的人,但我不记得有人棒到确实能在同一时间兼顾不相关的技术并且产出技艺精湛的应用。

也许会有例外。但我仍然认为大多数人做不到。人们当然可以掌握一件事情然后转向掌握另一件事情,但在这个过程中你不可避免地会忘记前者的细节。去年,我在等待裁员且没有什么事情要做的几个月里(我是最后无缘无故裁员中的一个,因为在品牌的最终出售之前我们所有的技术已经被换掉了),我花了一个月的时间在C++上,然后Node.js,最后Swift。今年继续这些语言(因为我的新工作做的是Objective-C)我发现我已经忘了大部分我所学过的。如果你不经常使用某样东西,记忆似乎丢到了脑后。在用PHP重写这个博客引擎的过程中我在我脑中把所有这些语言都搞混了。

如果你是在iOS上从Objective-C转到Swift,那至少还有些重叠。但Android和iOS不仅是不同的语言,一切都不同,从工具到如何布局去支持多个主要的OS发行和24,000多种不同的设备。仅仅是跟上所有这些年在六月WWDC的新变化就要花费大量的精力;雪上加霜的是Apple释放的示例代码在最新beta版的XCode里已经不能编译。就算你不写代码整天只是观看视频和阅读文档与示例代码,你怎么能一本正经的说你是一个专家呢?

给两个不同的移动OS环境添加复杂混乱的是现代Web开发,特别是那些某一天出现然后第二天消失的JavaScript框架。你需要三个脑袋才能跟上它。和我一起工作的JavaScript程序员也就够跟上一个(在这里是AngularJS)。

因此找一个能用JavaScript写iOS、Android和移动/桌面Web的人,使用现代的API并且仍然可以支持旧的OS版本,明白不同设计和UI方法的优缺点,特别是所有不同浏览器和Android设备中的微妙之处,并且在创纪录的时间内交付无bug的结果,是幻想。哦对,还要以低于市场的价格为你工作。

当我开始编程时,一切都极其原始,我只需要知道一门语言和一种OS,根本没有框架。甚至在我的两个创业公司里我只需要掌握C,通晓Macintosh和一些偶尔的68K汇编。今时不同以往。然而我们仍然只有一个大脑,并且大脑不服从摩尔定律,它们不能升级。

因此,如果你能(诚实地)同时做Android、iOS和移动Web,并且交付技艺精湛的结果,我向您致敬!但我真的希望你也能赚3倍的钱。

使用Rails 4.2+ 测试异步邮件

英文原文:https://blog.engineyard.com/2015/testing-async-emails-rails-42

如果你正在构建的应用需要发送邮件,我们都赞同绝不能阻塞控制器,因此异步发送必不可少。为了实现这一点,我们需要借助可以在后台处理任务的异步处理库将邮件发送代码从原始请求/响应周期中移出。

我们如何确信我们的代码在进行此更改时会如预期的那样行事?在这篇博文中,我们将研究测试它的一种方法。我们将使用MiniTest(因为Rails内置了这个框架),但是这里介绍的概念可以很容易地转换为RSpec。

好消息是从Rails 4.2开始,异步发送邮件比以前更容易。我们将在我们的示例中使用Sidekiq作为队列系统,但是由于ActionMailer#deliver_later构建在Active Job之上,该接口很干净,使得异步处理库的使用不可知。这意味着如果我刚才没有提到它,作为开发者或者用户的你将不会知情。设置队列系统是另一个话题,你可以在Getting Started With Active Job了解更多。

不要依赖具体

在我们的示例中,我们假设Sidekiq和它的依赖关系已经正确配置,因此特定于此场景的唯一一段代码是声明Active Job将使用哪个队列适配器:

1
2
3
4
5
6
7
8
# config/application.rb

module OurApp
  class Application < Rails::Application
    ...
    config.active_job.queue_adapter = :sidekiq
  end
end

Active Job在隐藏所有的实质的队列实现细节上做得很好,使得它对于Resque、Delayed Job或其它队列可以用同样的方式工作。所以如果我们要换用Sucker Punch,唯一要做的更改就是在引用相应的依赖包后,将队列适配器从:sidekiq切换到:sucker_punch。

基于Active Job

如果你刚开始使用Rails 4.2,或者对Active Job不太了解,Ben Lewis’ intro to Active Job是一个很好的起点。不过,这篇文章还留给我一个细节去盼望,即找到一个干净的、地道的方法来测试一切都如预期的那样工作。

根据本文的目标,我们假设你有:

  • Rails 4.2+
  • 已经设置好使用队列后端的Active Job(例如Sidekiq、Resque等)
  • 一个Mailer

任何Mailer都应该使用这里描述的概念,我们将用以下这封“欢迎邮件”来使我们的示例更实际:

1
2
3
4
5
6
7
8
9
10
11
12
#app/mailers/user_mailer.rb

class UserMailer < ActionMailer::Base
  default from: 'email@example.com'

  def welcome_email(user:)
    mail(
      to: user.email,
      subject: "Hi #{user.first_name}, and welcome!"
    )
  end
end

为了保持事情简单并专注于什么是重要的,我们想在用户注册后给他发送一封“欢迎邮件”。

这就像Rails指南邮件程序示例中的那样:

1
2
3
4
5
6
7
8
9
10
# app/controllers/users_controller.rb

class UsersController < ApplicationController
  ...
  def create
    ...
    # Yes, Ruby 2.0+ keyword arguments are preferred
    UserMailer.welcome_email(user: @user).deliver_later
  end
end

Mailer应该做最后的任务

接下来,我们要确保控制器内的任务能完成我们预期的。

在测试指南中,在Custom Assertions And Testing Jobs Inside Other Components上面的部分教给我们大约半打这样的自定义断言。

或许你的第一本能是立刻使用[assert_enqueued_jobs][assert-enqueued-jobs]来测试每次创建新用户时我们是否将邮件发送任务放入队列。

你可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
# test/controllers/users_controller_test.rb

require 'test_helper'

class UsersControllerTest < ActionController::TestCase
  ...
  test 'email is enqueued to be delivered later' do
    assert_enqueued_jobs 1 do
      post :create, {...}
    end
  end
end

即使你这样做,你也会惊讶于测试的失败,被告知assert_enqueued_jobs没有定义而无法使用。

这是因为我们的测试继承自ActionController::TestCase,它在编写时没有包含ActiveJob::TestHelper。

但是我们可以快速地修复这个问题:

1
2
3
4
5
6
# test/test_helper.rb

class ActionController::TestCase
  include ActiveJob::TestHelper
  ...
end

假设我们的代码能完成我们预期的,我们的测试现在应该是绿色的。

这是个好消息。我们可以继续重构代码,添加新功能,或者添加新测试。让我们跟随后者,测试我们的邮件是否被发送,以及它是否有预期的内容。

作为delivery_method选项被配置成:test的结果,ActionMailer能给我们提供一个所有发出邮件的数组。我们可以通过ActionMailer::Base.deliveries访问它。当发送的邮件串联时,测试邮件是否被真正发送就非常简单。我们要做的就是在行动完成后,检查交付数量增加1。用MiniTest来写的话,就像下面这样:

1
2
3
assert_difference 'ActionMailer::Base.deliveries.size', +1 do
  post :create, {...}
end

我们的测试是实时发生的,但正如我们在本文开头赞同的那样,不能阻塞控制器并在后台任务中发送邮件,所以我们现在需要协调一切,以确保我们的系统是确定的。因此,在我们的异步世界中,我们需要首先执行所有入队的任务,然后才能评估其结果。要执行待处理的Active Job任务,我们将使用perform_enqueued_jobs

1
2
3
4
5
6
7
8
9
test 'email is delivered with expected content' do
  perform_enqueued_jobs do
    post :create, {...}
    delivered_email = ActionMailer::Base.deliveries.last

    # assert our email has the expected content, e.g.
    assert_includes delivered_email.to, @user.email
  end
end

缩短反馈循环

迄今为止,我们都在进行功能性测试以确保我们的控制器如预期的那样行事。但是,当代码中的更改可能会破坏我们发送的邮件时,如何单元测试邮件程序才能缩短反馈循环并获得快速洞察?

Rails测试指南建议在这里使用fixtures,但我发现它们太过脆弱。特别是在开始的时候,当还在试验邮件的设计或内容时,变化会很快地使它们过时,导致测试变红。相反,我偏向使用assert_match来关注应该是邮件正文部分的关键元素。

为了这个目的和更多(比如抽象处理多部分邮件的逻辑),我们可以构建我们的自定义断言。这使我们能够扩展MiniTest标准断言Rails专用断言。这也是创建我们自己的领域专用语言(DSL)进行测试的一个很好的示例。

让我们在test目录中创建一个shared文件夹来托管我们的SharedMailerTests模块。我们的自定义断言看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# /test/shared/shared_mailer_tests.rb

module SharedMailerTests
  ...
  def assert_email_body_matches(matcher:, email:)
    if email.multipart?
      %w(text html).each do |part|
        assert_match matcher, email.send("#{part}_part").body.to_s
      end
    else
      assert_match matcher, email.body.to_s
    end
  end
end

接下来,我们要让邮件程序测试意识到这个自定义断言,所以我们把它混合在ActionMailer::TestCase中。我们可以以类似于我们在ActionController::TestCase中包含ActiveJob::TestHelper的方式来做到这点:

1
2
3
4
5
6
7
8
# test/test_helper.rb

require 'shared/shared_mailer_tests'
...
class ActionMailer::TestCase
  include SharedMailerTests
  ...
end

请注意,我们首先需要在test_helper中依赖我们的shared_mailer_tests。

这些就绪后,我们现在可以确保我们的邮件包含我们预期的关键元素。想象一下,我们想确保我们发送给用户的URL包含一些用于追踪的特定UTM参数。我们现在可以使用我们的自定义断言配合我们的老朋友perform_enqueued_jobs做到这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# test/mailers/user_mailer_test.rb

class ToolMailerTest < ActionMailer::TestCase
  ...
  test 'emailed URL contains expected UTM params' do
    UserMailer.welcome_email(user: @user).deliver_later

    perform_enqueued_jobs do
      refute ActionMailer::Base.deliveries.empty?

      delivered_email = ActionMailer::Base.deliveries.last
      %W(
        utm_campaign=#{@campaign}
        utm_content=#{@content}
        utm_medium=email
        utm_source=mandrill
      ).each do |utm_param|
        assert_email_body_matches utm_param, delivered_email
      end
    end
  end
end

结论

让ActionMailer基于Active Job之上,使得从同步发送邮件切换到通过队列发送邮件变得非常简单,就如同从deliver_now切换到deliver_later那样。

由于Active Job使得设置任务基础设施(不需要知道太多你正在使用的队列系统)如此容易,你的测试不再需要关心是否以后会切换到Sidekiq或Resque。

让你的测试设置正确以便于可以充分利用Active Job提供的新的自定义断言可能会有点棘手。希望本教程能使你对整个过程更加明白。

附:你有ActionMailer或Active Job的经验吗?有任何建议吗?有什么陷阱?我们很乐意听到你的经验。

保护你的Paperclip下载

英文原文:https://thewebfellas.com/blog/protecting-your-paperclip-downloads

去年11月份,当我首次在博客上谈到Paperclip时,我简要介绍了在控制器后面隐藏文件,而不是简单地将它们放在公共目录中展示给大家看。从那时起,我就注意到如何真正做到这点的问题定期地在Rails论坛上出现。几周前,我不得不弄清楚如何更新我们的某些代码来保护我们已经从本地文件系统迁移到Amazon S3的资产。所以我认为这可能是一个值得分享的技巧。

模型

我将使用来自我原先的Paperclip博文中的Getting clever with validations部分的Track模型的一个轻微更新版本。以下是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Track < ActiveRecord::Base

  has_attached_file :mp3,
                    :url => '/:class/:id/:style.:extension',
                    :path => ':rails_root/assets/:class/:id_partition/:style.:extension'

  validates_attachment_presence :mp3
  validates_attachment_content_type :mp3, :content_type => ['application/mp3', 'application/x-mp3', 'audio/mpeg', 'audio/mp3']
  validates_attachment_size :mp3, :less_than => 20.megabytes

  def downloadable?(user)
    user != :guest
  end

end

这段代码执行以下操作:

  • 定义含有名为mp3的Paperclip附件的Track模型。
  • 配置用于访问mp3资源的URL,例如/track/1/original.mp3。在URL中留下样式属性可以使未来版本的代码去做像生成轨道的10秒预览这样的事情(使用自定义的Paperclip处理器),这些预览可以使用像/track/1/preview.mp3这样的URL访问。
  • 配置Paperclip存储上传文件的路径(例如RAILS_ROOT/assets/tracks/000/000/001/original.mp3,其中RAILS_ROOT是Rails应用程序根目录的路径)——这里重要的是,文件存储在/public目录之外。
  • 定义验证器以确保是mp3文件被上传,该验证器包含有效的内容类型并且限制文件不能太大。
  • 定义downloadable?方法,可用于实现对轨道的用户访问权限。为简单起见,它只允许所有登录的用户访问轨道,不过你可以用应用程序需要的任何逻辑来替换它。

路由和控制器

现在,如果你尝试在视图中提供像下面这样的链接来下载mp3:

1
link_to('Listen', track.mp3.url)

当你点击它的时候将得到这样的路由错误:

1
2
Routing Error
No route matches "/tracks/1/original.mp3" with {:method=>:get}

我通常在routes.rb文件中使用map.connect将Paperclip URL映射到控制器中的download动作,就像下面这样:

1
2
3
ActionController::Routing::Routes.draw do |map|
  map.connect 'tracks/:id/:style.:format', :controller => 'tracks', :action => 'download', :conditions => { :method => :get }
end

不需要使用命名路由,因为你不太可能需要通过名称引用路由。如果需要的话,通过映射到自定义的download动作,你还可以在控制器中提供标准的资源丰富的CRUD动作。清教徒式的REST粉丝可能会坚持将下载映射到单独的资源,并且使用POST请求创建一个新的下载资源:如果你打算做更多而不仅仅是将文件流传输到客户端(例如日志统计,更新账单信息),这可能值得考虑,否则为什么要复杂化事情?

然后,TracksController需要一个download动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TracksController < ApplicationController

  SEND_FILE_METHOD = :default

  def download
    head(:not_found) and return if (track = Track.find_by_id(params[:id])).nil?
    head(:forbidden) and return unless track.downloadable?(current_user)

    path = track.mp3.path(params[:style])
    head(:bad_request) and return unless File.exist?(path) && params[:format].to_s == File.extname(path).gsub(/^\.+/, '')

    send_file_options = { :type => File.mime_type?(path) }

    case SEND_FILE_METHOD
      when :apache then send_file_options[:x_sendfile] = true
      when :nginx then head(:x_accel_redirect => path.gsub(Rails.root, ''), :content_type => send_file_options[:type]) and return
    end

    send_file(path, send_file_options)
  end

end

控制器中有很多事情发生,这里是download动作的细节:

  1. 尝试使用:id参数找到Track模型,如果轨道不存在则返回404 Not Found响应。
  2. 把当前用户传递给downloadable?方法,决定是否允许用户下载轨道。
    • 这里假设current_user是应用程序的身份验证代码提供的方法,如果没有用户登录则返回:guest符号,否则返回User对象。这里以及downloadable?方法中使用的实际代码可能需要修改以适应你自己的身份验证代码。
    • 如果不允许用户下载轨道,控制器将返回403 Forbidden响应。
  3. 然后为传递的:style参数生成服务器上的mp3文件的路径。
  4. 然后,控制器确保文件存在(如果使用无效的:style参数则不会),并且请求的文件扩展名与文件的实际扩展名相匹配。如果需要,它返回400 Bad Request。
  5. 被用于send_file方法的散列选项将与mp3文件的MIME类型一起被初始化。
    • 我在这里使用mimetype-fu插件(使用ruby script/plugin install git://github.com/mattetti/mimetype-fu.git安装),而不是mp3附件的content_type属性,以允许将来不同的样式可能会使用不同的文件类型的功能。
  6. 控制器支持标准流传输,使用Lighttpd/Apache X-Sendfile或Nginx X-Accel-Redirect方法下载文件。
    • 为简单起见,这里使用SEND_FILE_METHOD常量来配置要使用的方法,但是在实际的应用程序中,应该将其存储在某种配置设置中(我推荐使用Luke Redpath的SimpleConfig插件进行此类操作)。
    • 取决于配置的流传输方法,控制器既可以使用send_file(用于标准和X-Sendfile流传输)也可以使用带报头的响应(用于X-Accel-Redirect流传输)。
    • 在Rails应用程序中我们通常使用Nginx,因此我们借助如下的Nginx配置以使用X-Accel-Redirect方法进行文件的流传输:
1
2
3
4
location /assets/ {
  root /path/to/rails_root;
  internal;
}

其中/path/to/rails_root包含我们的Rails应用程序根目录的完整路径。

视图

Track模型通过downloadable?方法提供访问控制逻辑,而在TracksController中的download动作处理mp3文件流传输,这两者使得使用带有Paperclip提供的url方法的link_to帮助器在视图中提供下载链接成为可能。例如,index视图可能如下所示:

1
2
3
4
5
<ul>
<% @tracks.each do |track| %>
  <li><%= link_to('Listen', track.mp3.url) %></li>
<% end %>
</ul>

扩展到Amazon S3

更新!Paperclip的最新版本包含到期的URL support

如果你的mp3存储在本地文件系统上,上面的代码可以正常地工作,但如果你的站点开始增长,并且你需要在存储空间和下载容量方面进行扩展,那么你可能需要迁移到S3。

Paperclip提供的S3存储模块目前使用RightAWS,而2.3.1+版已经开始使用AWS::S3作为替代。请注意,如果要使用位于欧洲的S3存储桶,则此更改确实会导致一些问题,因此如果这对你来说是个问题的话,你可能需要继续使用2.3.0版本。我将在下面的代码中覆盖这两个版本。

更改存储模块

首先要更改的是Track模型中的has_attached_file定义:

1
2
3
4
5
6
7
has_attached_file :mp3,
                  :url => ':s3_domain_url',
                  :path => 'assets/:class/:id/:style.:extension',
                  :storage => :s3,
                  :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
                  :s3_permissions => 'authenticated-read',
                  :s3_protocol => 'http'

这里最明显的更改分别依次为指定存储模块,凭据文件位置,上传文件权限和通信协议方面的:storage、:s3_credentials、:s3_permissions和:s3_protocol选项。

凭据YAML文件用于指定你的S3帐户的访问密钥、私有访问密钥和存储桶名称,应该如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
common: &common
  access_key_id: your_access_key_id_goes_here
  secret_access_key: your_secret_access_key_goes_here

development:
  <<: *common
  bucket: app-name-development

test:
  <<: *common
  bucket: app-name-test

production:
  <<: *common
  bucket: app-name-production

为每个环境配置单独的S3桶是个好主意,可以防止意外混淆测试文件与生产文件!

:s3_permissions选项允许你指定四个固定访问策略中的一个,在这种情况下,authenticated-read被用于设置读取访问仅限于对象所有者或经过身份验证的用户。

对:url和:path选项的更改略微不那么明显::url被设置为“:s3_domain_url”,它是使用虚拟托管风格的桶访问的Paperclip插补(如果使用欧洲桶这尤其重要,因为这是首选的访问方式);:path被Paperclip用于生成存储在S3上的对象的密钥名称。

通过这些更改,上传到Track模型的新文件将使用从:path选项生成的密钥存储在相应的S3桶中,例如http://app-name-development.s3.amazonaws.com/assets/tracks/1/original.mp3。

重定向不再有流传输和时间

迁移到S3的好处之一是,你的服务器不再需要自己涉及将数据流传输到客户端(使用X-SendFile和X-Accel-Redirect等技术可以帮助你给Rails进程解除负担,但服务器仍然必须做所有的工作)。作为将文件数据发送给客户端的替代,控制器中的download动作现在需要处理权限检查,然后将客户端重定向到S3上的文件以进行下载。

然而,这会有个问题,因为上传文件正在使用阻止公开访问它们的authenticated-read固定访问策略。幸运的是,S3提供一种方法,可以为仅在特定时间段内运行的私有内容生成已验证的URL:然后我们的控制器可以将客户端重定向到此临时URL以启动下载,但如果有人获取URL的详细信息并尝试在以后访问该文件,那么他们将被告知该URL已经过期。

更新的代码如下所示:

1
2
3
4
5
6
7
8
9
def download
  head(:not_found) and return if (track = Track.find_by_id(params[:id])).nil?
  head(:forbidden) and return unless track.downloadable?(current_user)

  path = track.mp3.path(params[:style])
  head(:bad_request) and return unless params[:format].to_s == File.extname(path).gsub(/^\.+/, '')

  redirect_to(AWS::S3::S3Object.url_for(path, track.mp3.bucket_name, :expires_in => 10.seconds))
end

在控制器中有两个主要区别:

  • 没有检查以确保对象存在,因为这将增加到S3的额外请求的开销——让S3去为不存在的对象返回适当的响应吧。
  • 作为流传输文件的替代,它重定向到使用url_for方法生成的临时URL。临时URL设置为在10秒后过期,该时间应足够长,以便重定向去启动下载:如果下载时间超过10秒,只要已经开始,它将继续下去,直到完成为止。

如果你正在使用Paperclip 2.3.0或更旧的版本,因为RightAWS的缘故,控制器重定向代码应如下所示:

1
redirect_to(track.mp3.s3.interface.get_link(track.mp3.s3_bucket.to_s, path, 10.seconds))

重新考虑视图

当使用本地文件系统存储时,Paperclip附件的url方法可被用于链接到TracksController的download动作,但现在文件托管在S3上,url方法返回的是S3桶的URL,这不是我们想要的。

这里有几个选择,路由可以被更改为命名路由,然后视图可以使用如下所示的代码:

1
link_to('Listen', download_track_path(track.id, 'original', 'mp3')

我认为更好的方法是在Track模型中添加一个新方法,使用Paperclip插补来生成下载URL。与此同时,还可以将验证S3 URL的代码从控制器中移到模型中。这里是完整的Track模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Track < ActiveRecord::Base

  has_attached_file :mp3,
                    :url => ':s3_domain_url',
                    :path => 'assets/:class/:id/:style.:extension',
                    :storage => :s3,
                    :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
                    :s3_permissions => 'authenticated-read',
                    :s3_protocol => 'http'

  validates_attachment_presence :mp3
  validates_attachment_content_type :mp3, :content_type => ['application/mp3', 'application/x-mp3', 'audio/mpeg', 'audio/mp3']
  validates_attachment_size :mp3, :less_than => 20.megabytes

  def downloadable?(user)
    user != :guest
  end

  def download_url(style = nil, include_updated_timestamp = true)
    url = Paperclip::Interpolations.interpolate('/:class/:id/:style.:extension', mp3, style || mp3.default_style)
    include_updated_timestamp && mp3.updated_at ? [url, mp3.updated_at].compact.join(url.include?("?") ? "&" : "?") : url
  end

  def authenticated_url(style = nil, expires_in = 10.seconds)
    AWS::S3::S3Object.url_for(mp3.path(style || mp3.default_style), mp3.bucket_name, :expires_in => expires_in, :use_ssl => mp3.s3_protocol == 'https')
  end

end

对于RightAWS (Paperclip <= 2.3.0),验证URL的代码略有不同:

1
2
3
def authenticated_url(style = nil, expires_in = 10.seconds)
  mp3.s3.interface.get_link(mp3.s3_bucket.to_s, mp3.path(style || mp3.default_style), expires_in)
end

然后整理download动作,将重定向更改为:

1
redirect_to(track.authenticated_url(params[:style]))

现在视图可以链接到下载了:

1
link_to('Listen', track.download_url)

结束

希望这里我给了你足够的信息,以便你开始在自己的Rails应用程序中实施受保护的下载。但是请不要忘记,如果你使用的是欧洲S3桶,需要进行一些修补,否则会出现以下错误:

1
AWS::S3::PermanentRedirect: The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint.

当你尝试并且上传时——彻底地修复将是另一天的工作!

FAQ

如何强制下载文件,而不是在浏览器中显示?

如果你使用的是文件系统存储模块,那么当使用send_file方法时,Rails会给attachment设置Content-Disposition报头,你不需要做任何事情。IE可能仍然会坚持在线显示文件,所以你可能需要用点蛮力,在控制器中给application/octet-stream设置Content-Type报头:

1
send_file_options = { :type => 'application/octet-stream' }

当然你也可以使用一些简单的浏览器嗅探来为智能浏览器返回正确的Content-Type,以及为IE返回application/octet-stream报头。

如果使用S3存储模块,那么你需要将s3_headers选项添加到模型的has_attached_file定义中:

1
2
3
4
5
6
7
8
has_attached_file :mp3,
            :url => ':s3_domain_url',
            :path => 'assets/:class/:id/:style.:extension',
            :storage => :s3,
            :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
            :s3_permissions => 'authenticated-read',
            :s3_protocol => 'http',
            :s3_headers => { :content_disposition => 'attachment' }

当控制器重定向到S3 URL时,Amazon将发送你在此处指定的报头,强制下载。就像使用文件系统存储一样,你也可以使用这种方法强制Content-Type报头,尽管你将无法基于用户浏览器使用任何类型的浏览器嗅探来选择内容类型:

1
2
3
4
5
6
7
8
has_attached_file :mp3,
            :url => ':s3_domain_url',
            :path => 'assets/:class/:id/:style.:extension',
            :storage => :s3,
            :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
            :s3_permissions => 'authenticated-read',
            :s3_protocol => 'http',
            :s3_headers => { :content_type => 'application/octet-stream', :content_disposition => 'attachment' }

使用Rails进行图片处理

英文原文:https://www.sitepoint.com/image-processing-rails/

图片是任何应用程序的重要部分。从社交网络到简单的缺陷跟踪器,图片都扮演着重要的角色。不过,管理图片不是件容易的事情,需要提前做大量的规划。

在本文中,我将演示如何用Rails实现这些。我将向你展示如何处理你的图片,并在后台创建多个版本。我们还将看到如何通过压缩这些图片来提高页面的性能,而不损失质量。

起步

本教程中的示例在Rails 4.2上运行,使用MongoDB数据库,以及使用HAML渲染视图。不过,这里使用的代码片段应该能与任何版本的Rails兼容,尽管有轻微的配置差异。

配置环境

ImageMagick是POSIX系统图片处理库。如果你的系统没有安装ImageMagick,它可以用操作系统的软件包管理器安装。

在Ubuntu上:

1
2
3
sudo apt-get -y install imagemagick
sudo apt-get -y install libmagic-dev
sudo apt-get -y install libmagickwand-dev

在Mac OS X上,推荐使用Homebrew:

1
brew install imagemagick

现在,我们需要Ruby适配器来连接本地的ImageMagick库。我个人更偏爱MiniMagick,因为它是轻量级的,含有典型应用程序需要的几乎所有东西:

1
2
3
# Gemfile

gem 'mini_magick'

热身运动

在正式构建任何东西之前,让我们先试试MiniMagick的某些功能。打开Rails控制台(rails c)并运行以下命令:

1
2
3
4
5
6
7
8
9
# Open an image from a website

image = MiniMagick::Image.open("https://s3.amazonaws.com/StartupStockPhotos/20140808_StartupStockPhotos/85.jpg")

# Get the Image's width
image.width  # 4928

# Get the image's height
image.height  #3264

我的天哪,这图好大。让我们看看能否调整它的大小以适应我们的iPad:

1
2
3
4
5
image.resize "2048x1536"

# Now get the image's new width and height

p "Width is => #{image.width} and height is => #{image.height}"

调整速度很快,只是修改的文件存储在哪里?

1
image.path  # temp path

处理过的图片存储在临时路径中,并且随时可能被清理。要将其持久化到磁盘,只需要调用write方法即可:

1
image.write "public/uploads/test.jpg"

转换图片

你将要执行的最常用操作之一是将图片转换为不同的格式。MiniMagick使这非常简单:

1
2
image.format "png"
image.write "public/uploads/test.png"

你也可以在单个块中组合多个操作:

1
2
3
4
5
6
7
8
9
image.combine_options do |i|
  i.resize "2048x1536"
  i.flip
  i.rotate "-45"
  i.blur "0x15"
end
image.write "public/uploads/blur.png"

![Some weird result](blur.png)

好吧,在这里我做的有点过火。我要给自己辩解一下,我只是试图给你展示可以用MiniMagick做的所有酷的东西。

现在,让我们来看看如何将其与Rails应用程序相结合。

上传文件

Carrierwave是个奇妙的gem,它简化了Ruby中的文件上传。它还可以很好地与MiniMagick互动,使我们的生活更简单。

1
2
3
4
# Gemfile

gem 'carrierwave'
gem 'carrierwave-mongoid', :require => 'carrierwave/mongoid'

注意:如果你使用的是ActiveRecord或DataMapper,配置将略有不同,而Carrierwave官方文档会告诉你正确的方法。

批量获取所有这些gem:

1
bundle install

创建首个上传器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#app/uploaders/image_uploader.rb

class ImageUploader < CarrierWave::Uploader::Base
  # Include RMagick or MiniMagick support:
  include CarrierWave::MiniMagick

  # Choose what kind of storage to use for this Uploader:
  storage :file
  # Override the directory where uploaded files will be stored.
  # This is a sensible default for uploaders that are meant to be mounted:
  def store_dir
    "uploads/images"
  end
end

这里的代码是不言自明的。storage:file指示服务器将图片存储在本地服务器上,store_dir指定存储位置。

由于这些文件是通过互联网发送的,因此最佳的做法是始终过滤进来的文件:

1
2
3
4
5
6
7
8
# app/uploaders/image_uploader.rb
...
# Add a white list of extensions which are allowed to be uploaded.
# For images you might use something like this:
def extension_white_list
  %w(jpg jpeg png gif)
end
...

此代码片段过滤掉未在此处指定的文件类型。这绝对不是万无一失的,但它可以作为抵御任何阻碍攻击的第一级过滤器。

将此上传器添加到我们的图片模型中:

1
2
3
4
5
6
7
8
9
10
11
# app/models/image.rb

class Image
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Paranoia
  include Mongoid::Attributes::Dynamic
  include Rails.application.routes.url_helpers

  mount_uploader :media, ImageUploader, mount_on: :media_filename
end

编辑image_uploader.rb去处理上传的图片:

1
2
3
4
5
6
# app/uploaders/image_uploader.rb

...
process :resize_to_fill => [200, 200]
process :convert => 'png'
...

尝试在Rails控制台创建一个新图片:

1
2
3
media = File.open("/Users/test/Desktop/image/jpg")
img = Image.new(:media => media)
img.save

上传的图片保存在store_dir目录。而且上传的图片将立即被处理并覆写为200x200的图片。我们不会为任何未来的编辑而保有原始文件的副本。为避免这种情况,需要创建文件的多个版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
# app/uploaders/image_uploader.rb

...
version :thumb do
  process :resize_to_fit => [100, 100]
  process :convert => 'jpg'
end

version :cover   do
  process :resize_to_fit => [240, 180]
  process :convert => 'jpg'
end
...

除原始图片之外,还将创建2个新版本。检查Carrierwave创建的版本:

1
2
img.media.versions[:thumb]  # returns the thumb image instance
img.media.versions[:cover]  # returns the cover image instance

你注意到这些图片是瞬间生成的吗?这意味着图片转换发生在同一个线程中,并且执行被阻塞直到转换完成。在生产应用程序中,在同一个线程中创建图片的多个版本是不应该的。相反,我们应该有条件地处理这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# app/uploaders/image_uploader/rb

...
version :cover, :if => :is_live? do
  process :resize_to_fit => [240, 180]
  process :convert => 'jpg'
end

def is_live?(img = nil)
  @is_live
end

def is_live=(value)
  @is_live = value
end
...

现在,当我们尝试创建一个新图片时,封面版本将不会生成。我们可以在需要时通过简单的运行以下代码来手动触发:

1
2
3
img.media.is_live = true
img.save
img.media.recreate_versions! :cover

该代码也在前台运行,是个阻塞操作,但至少是推迟到最后可能的一刻。我们可以再进一步,通过Resque在后台运行这些代码:

1
2
3
4
5
6
7
8
9
10
# lib/resque/image_queue.rb
class ImageQueue
  @queue = :image_queue
  def self.perform(image_id)
    image = Image.find image_id
    img.media.is_live= true
    img.save
    img.media.recreate_versions! :cover
  end
end

然后,把它们放入队列:

1
Resque.enqueue(ImageQueue, img.id.to_s)

提高性能

重量级的图片往往会减慢网站的速度。减少页面大小的一种方法是压缩这些图片。Carrierwave Image Optimizer可以帮助我们轻松地压缩图片,而没有任何麻烦。

将其添加到你的Gemfile中:

1
gem 'carrierwave-imageoptimizer'

然后编辑image_uploader.rb文件:

1
2
3
4
5
6
7
# app/uploaders/image_uploader.rb

...
include CarrierWave::ImageOptimizer
process :optimize
process :quality => 100
...

这段代码压缩所有的图片,没有任何视觉损失。这种方式的做法是所有图片的元信息被剥离。平均而言,这减少了约20-30%的大小。

总结

图片处理是非常垂直的技术,我们几乎只触及表面。我们可以用它构建这么多酷的东西。我希望我已经用这篇文章引起你的兴趣。请在评论中分享你的想法。

Elasticsearch的RESTful API

CURD的URL格式:

1
http://localhost:9200/<index>/<type>/[<id>]

id是可选的,不提供的话Elasticsearch会自动生成。index和type将信息进行分层,便于管理。可以将index理解为数据库,type理解为数据表。

创建

1
2
3
4
5
# 使用自动生成ID的方式新建纪录
curl -XPOST localhost:9200/<index>/<type> -d '{ "tag" : "bad" }'

# 使用指定的ID新建记录
curl -XPOST localhost:9200/<index>/<type>/3 -d '{ "tag" : "bad" }'

查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 查询所有的index和type的记录
curl -XGET localhost:9200/_search?pretty

# 查询某个index下所有type的记录
curl -XGET localhost:9200/<index>/_search?pretty

# 查询某个index下某个type下所有的记录
curl -XGET localhost:9200/<index>/<type>/_search?pretty

# 使用参数查询所有的记录
curl -XGET localhost:9200/_search?q=tag:bad&pretty

# 使用参数查询某个index下的所有记录
curl -XGET localhost:9200/<index>/_search?q=tag:bad&pretty

# 使用参数查询某个index下某个type下所有的记录
curl -XGET localhost:9200/<index>/<type>/_search?q=tag:bad&pretty

# 使用JSON参数查询所有的记录,-d代表一个JSON格式的对象
curl -XGET localhost:9200/_search?pretty -d '{ "query" : { "term" : { "tag" : "bad" } } }'

# 使用JSON参数查询某个index下的所有记录
curl -XGET localhost:9200/<index>/_search?pretty -d '{ "query" : { "term" : { "tag" : "bad" } } }'

# 使用JSON参数查询某个index下某个type下所有的记录
curl -XGET localhost:9200/<index>/<type>/_search?pretty -d '{ "query" : { "term" : { "tag" : "bad" } } }'

更新

1
curl -XPUT localhost:9200/<index>/<type>/3 -d '{ "tag" : "good" }'

删除

1
curl -XDELETE localhost:9200/<index>

Elasticsearch安装

Elasticsearch是一款基于Lucene构建的开源分布式全文检索服务器。提供RESTful API,采用多shard的方式保证数据安全,提供自动resharding的功能,能够很轻松地进行大规模的横向扩展,以支撑PB级的结构化和非结构化海量数据的处理。

安装Java 1.7

1
2
3
4
5
6
mkdir /usr/java
cd /usr/java
wget --no-check-certificate --no-cookies --header "Cookie: oraclelicense=accept-securebackup-cookie" http://download.oracle.com/otn-pub/java/jdk/7u67-b01/jdk-7u67-linux-x64.rpm
rpm -ivh jdk-7u67-linux-x64.rpm
java -version
echo $JAVA_HOME

安装Elasticsearch 1.4.1

1
2
3
4
5
6
mkdir /usr/elasticsearch
cd /usr/elasticsearch
wget https://download.elastic.co/elasticsearch/elasticsearch/elasticsearch-1.4.1.tar.gz
tar -xvf elasticsearch-1.4.1.tar.gz
cd elasticsearch-1.4.1
./bin/elasticsearch

然后访问http://localhost:9200/?pretty就可以看到类似下面的返回:

1
2
3
4
5
6
7
8
9
10
{
  "status" : 200,
  "name" : "Powerpax",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "1.4.1",
    "lucene_version" : "4.10.2"
  },
  "tagline" : "You Know, for Search"
}

只是运行起来是不够的,通常我们需要将Elasticsearch安装成服务,设置成开机自启动什么的。这要用到elasticsearch-servicewrapper了。下载解压后把service文件夹拷贝到elasticsearch-1.4.1/bin目录下。

1
2
./bin/service/elasticsearch install  # 安装服务
./bin/service/elasticsearch start  # 运行服务

其它选项:

1
2
3
console 以前台方式运行Elasticsearch
stop 停止Elasticsearch
remove 移除系统启动中的Elasticsearch服务(init.d/service)

需要注意的是,在小内存机器上运行时,需要限制下内存大小,否则服务会无法启动,出现如下警告信息:

1
2
3
Starting Elasticsearch...
Waiting for Elasticsearch......................
WARNING: Elasticsearch may have failed to start.

打开bin/service/elasticsearch.conf文件,设置Elasticsearch能够分配的JVM内存大小。一般情况下,设置成总内存的50%比较好。

1
set.default.ES_HEAP_SIZE=512

如果要限制ES_MIN_MEM和ES_MAX_MEM,建议设置成一样大,避免出现频繁的内存分配。

为什么我们不能用估算房屋同样的方法估算软件项目

英文原文:http://www.summa-tech.com/blog/2009/01/28/why-we-cant-estimate-software-project-the-same-way-we-estimate-houses

把构造软件类比成建造房屋是非常有用的,但也有缺陷。

尽管软件建设和住宅建设都是工程实践,但比起软件,我们在估算建造房屋的成本和精力方面更成功。

词汇障碍

因为我们大多数人一生都住在房子里,我们开发了一套定义良好的、可以理解的、几乎通用的词汇来描述和讨论关于我们称之为家的地方。当被问及我们的房子是什么样子的,我们可以很容易地回答。当计划一所新房子时,我们可以极其肯定地讨论我们想要什么,并确信建筑师和工程师会明白我们谈论的,反之亦然。我们可能不明白水管设施和电气细节,但我们知道电源插座要放在哪里,知道房间的大小,知道要有多少车库门。

当我们谈论软件时,就不是那么有效了。有太多来自业务和技术方面的新术语,我们需要依赖于现实世界中的类比去解释它们。不仅发生在业务和技术人员之间,甚至还发生在业务人员和业务人员,技术人员和技术人员之间。我们不习惯于去描述软件需求因为所有软件的抽象,在估算时沟通经常受到噪音、误解、缺乏眼界的影响,增加了真正需要构建的不确定性。

物理

现实世界有非常强大和稳定的规则,比如重力,这些规则会在盖房子的时候施加约束。我们知道我们必须在构建二楼前先完成地下室。我们不能在地基完成后增加层的数量。在水管设施铺设后更改浴室的位置将会是非常非常昂贵的。

在软件项目中,我们生活在一个较少规则的世界里,就像《黑客帝国》。从技术上讲,我们可以在同一时间构造所有的应用层。它可以被设计成使用不同的数据库,运行在不同的服务器,或支持不同的语言。它可以通过浏览器、手机、或其它无线设备访问。选项几乎是无穷无尽的。

正是这种自由和灵活性,在过去的40年里驱动了软件的巨大的进步,但在同时,它也是无数软件项目失败的原因。至关重要的是需求要面对现实,让它们遵守一些基本规则,即使它们不受宇宙物理规律的约束。

程序

物理和几千年建设的结合已经带来了如何去建造一座房子的一套可靠的程序,虽然总是会有新的材料和改进的技术,核心概念都是相同的。油漆房子的过程几百年来几乎没有变化。

我们仍然处于软件工程的早期阶段。大量的“直觉”仍在估算软件时使用,实际情况是直觉还没有被证明非常有效。

材料和标准

只有数量有限的材料能被实际地用在建造房屋上。从技术上来讲,可能有成千上百的选项去建造一堵墙:夹板、混凝土、钢铁、沙子、玻璃,但在家具建材零售店里这些选项是非常有限的。涂料的类型,门窗的模型很多但有限。当购买一个水槽时,它很容易兼容已有的水管设施的几率非常高。电器有着相同的电压,灯泡也是兼容的。计划建造一所房子的一切都是兼容的,材料更是普及的,把不确定性降低到了非常小的水平,提高了估算的准确性。

软件行业确实有一些标准,但是它们处于层的最低水平,例如网络协议和文件系统。服务器和产品的集成仍处于布线阶段,XML和Web Service还有很长的路要走,在它们和建筑业达到相同级别的兼容性前,如果这是可能的。

各种各样连接到数据库和构建软件的选项和方法增加了复杂性,提高了每个人参与软件构建工作的学习曲线。我不是说所有这些选项都是不好的,但它确实给估算阶段增加了不确定性,所以产生了复杂性。

角色

每次我路过建筑工地,都会看到很多帽子,一些在积极工作,一些在等待时机采取行动。但最好的部分是我从来没有见过有人同时戴两顶帽子。角色界定的很明确,工人们专业从事于非常具体的领域。

在大型软件项目中也有一些角色被定义,但还远远没有达到建筑业相同级别的专业化。通常团队成员需要戴上很多帽子,结果是,有时候他们会执行那些他们不是专家的任务,这就增加了他们提供的估算的不确定性。

“很多帽子”现象的一个很好例子是“Webmaster”,该角色用于描述那个做网页设计、创建动画图片、编写HTML和Perl代码、配置数据库、管理网络和邮件服务器的家伙。幸运的是现在Webmaster是个很少使用的术语,因为所有这些活动现在都分配给了不同的角色,像网页设计师、DBA,程序员和系统管理员。我们确实在走向专业化,但还是有很长的路要走。

我并不是建议我们停止使用“让我们像建造房屋那样构造软件”的类比,但我们必须意识到这个比喻的局限性。一旦我们知道局限性我们将能更好地定位讨论这一差异,以及提供建议如何解决它们。

TabActivity is deprecated

最近在整理Android Tab导航总结的代码时发现 TabActivity在API 13中被标记为过期了,所以就去寻找它的替换类,能尽量满足最小修改的要求。发现可以使用FragmentActivity来替代,Fragment组件作为标签页添加。

Fragment是Android 3.0引入的一个新概念,主要是为了适应各种不同的屏幕大小,它非常类似于Activity,可以像Activity一样包含布局,但是不能单独存在,只能存在于Activity中。下图是Fragment在不同屏幕上的显示以及Fragment与所在Activity的关系:

android-fragments

重构前的布局:

1
2
3
4
5
6
7
8
MainActivity extends TabActivity
    TabHost - tabhost
        LinearLayout
            TabWidget - tabs
            FrameLayout - tabcontent
                TabSpec (Activity)
                ...
                TabSpec (Activity)

重构后的布局:

1
2
3
4
5
6
7
8
MainActivity extends FragmentActivity
    TabHost - tabhost
        LinearLayout
            TabWidget - tabs
            FrameLayout - tabcontent
                TabSpec (Fragment)
                ...
                TabSpec (Fragment)

把TabLeftActivity和TabRightActivity分别改成LeftFragment和RightFragment,并且要把其中的onCreate()方法改成onCreateView()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class LeftFragment extends Fragment {
    private View rootView;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        rootView = inflater.inflate(R.layout.tab_left, container, false);

        // do something
        // 不能直接使用findViewById()方法,必须加上rootView前缀
        // 如果要引用当前绑定的Activity实例,使用getActivity()方法

        return rootView;
    }

然后在main.xml中的FrameLayout里添加两个Fragment组件:

1
2
3
4
5
6
7
8
9
<fragment android:name="com.example.fragments.LeftFragment"
    android:id="@+id/fragment_left"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

<fragment android:name="com.example.fragments.RightFragment"
    android:id="@+id/fragment_right"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

把MainActivity改成从FragmentActivity继承。这里不能像TabActivity一样直接用getTabHost(),需要改成如下代码:

1
2
TabHost tabHost = (TabHost)findViewById(android.R.id.tabhost);
tabHost.setup();

到这边就已经完成了,其它tabHost.addTab的使用方式一模一样。

根据Exif时间信息归类照片

先要把Exif中的信息解析出来,得到其中的时间,有个exif的gem很不错。然后再根据时间创建目录,把照片移动到对应的目录中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require 'rubygems'
require 'exifr'
require 'fileutils'

root = ARGV[0] || Dir.pwd

Dir.foreach(root) do |file|
  next if File.extname(file) != '.jpg'

  obj = EXIFR::JPEG.new(file)
  date_time_original = obj.exif.date_time_original if obj.exif?
  next if date_time_original.nil?

  dir = date_time_original.year.to_s
  Dir.mkdir(dir) unless Dir.exist?(dir)
  FileUtils.move(file, dir)
end

重构Rails代码

在以前写的博文部署应用到Heroku时的问题里有这么一段话:

股票功能需要导入交割单文件,因为导入后的文本文件不再使用,可以把上传路径由public/uploads改为tmp,这样就避免了Heroku不能写文件的问题。

下面是那时候写的导入代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class StocksController < ApplicationController
  UPLOADS_DIRECTORY = File.join("#{Rails.root}", "public/uploads")

  def import
    filename = upload_file(params[:file])
    lines = File.readlines("#{UPLOADS_DIRECTORY}/#{filename}")
    lines.each do |line|
      # do something
    end
    redirect_to stocks_url
  end

  protected
    def upload_file(file)
      if !file.original_filename.empty?
        @filename = get_file_name(file.original_filename)
        Dir.mkdir("#{UPLOADS_DIRECTORY}") unless Dir.exist?("#{UPLOADS_DIRECTORY}")
        File.open("#{UPLOADS_DIRECTORY}/#{@filename}", "wb") do |f|
          f.write(file.read)
        end
        return @filename
      end
    end

    def get_file_name(filename)
      if !filename.nil?
        # does not support chinese filename?
        Time.now.strftime("%Y%m%d%H%M%S") + '.txt'
      end
    end
end

这里的实现方法是先将上传文件保存到服务器上应用的public/uploads目录中,然后再读取和处理。

其实根本不需要写的这么复杂,因为导入的文件被读取一次后就不再使用了。所以在当时写代码的时候一直有这样的想法,如果能直接获得上传文件的数据,那么就不需要再另外去写保存和读取文件的代码了。

事实也是如此。通过表单提交的file字段数据会首先在服务器上形成临时文件,这时其实可以通过临时文件的路径来读取上传文件的数据。

根据该想法重构后的代码如下:

1
2
3
4
5
6
7
8
class StocksController < ApplicationController
  def import
    lines = File.readlines(params[:file].tempfile.to_path.to_s)
    lines.each do |line|
      # do something
    end
    redirect_to stocks_url
  end

重构后的代码果然清爽多了,不过还是有改进的空间。

作为控制器,它应该只负责接收请求,并返回响应。而具体的业务逻辑,则应该交由模型去完成。下面是依照该理念再次重构后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class StocksController < ApplicationController
  def import
    Stock.import(params[:file])
    redirect_to stocks_url
  end
end

class Stock < ActiveRecord::Base
  def self.import(file)
    lines = File.readlines(file.tempfile.to_path.to_s)
    lines.each do |line|
      # do something
    end
  end
end