Elixir / Phoenix — 上傳圖片到S3 (with Arc / ExAws)

Deniel Chiang
15 min readFeb 27, 2020

--

當我們做Web Apps的時候,很常會有圖片上傳的功能,我們上一篇有介紹過上傳到App Sever,這一篇來講比較real world的上傳方法。

Photo by Kolar.io on Unsplash
Photo by Kolar.io on Unsplash

上傳到AWS S3是個算是很標準的解法來解多媒體publication,讀取量沒那麼大的可以直接用,讀取量大的還可以加上AWS Cloudfront(CDN)來操作,或者是考慮其他的Storage Service;我自己也不太清楚台灣的客戶群,大家喜歡放在哪裏,畢竟這個還是跟開發預算以及維護營運費用相當有關係的,有大大願意分享一下這方面的資訊嗎?

Photo by Daniel Eledut on Unsplash

建議如果對Elixir / Phoenix 不熟的人先看一下上一篇,針對一些專案的基本操作會敘述的比較詳細;這篇會直接切入主題針對在 Arc / ExAws

Cut the shit…正式進入主題吧!

環境版本

$ asdf current
elixir 1.10.0 (set by /Users/denielchiang/Develop/elixir/image_upload_demo/.tool-versions)
erlang 22.2.4 (set by /Users/denielchiang/Develop/elixir/image_upload_demo/.tool-versions)
nodejs 13.7.0 (set by /Users/denielchiang/Develop/elixir/image_upload_demo/.tool-versions)$ mix phx.new -v
Phoenix v1.4.12

產生專案

產生一個專案

$ mix phx.new image_upload_s3_demo
* creating image_upload_s3_demo/config/config.exs
* creating image_upload_s3_demo/config/dev.exs
* creating image_upload_s3_demo/config/prod.exs
................................
Fetch and install dependencies? [Yn] y

或者是你也可以直接 clone 一個空專案回家?

$ git clone git@github.com:denielchiang/image_upload_s3_demo.git

安裝Dep Libs

defp deps do
[
# Upload image to S3
{:arc, "~> 0.11.0"},
{:ex_aws, "~> 2.1"},
{:ex_aws_s3, "~> 2.0"},
{:hackney, "~> 1.9"},
{:sweet_xml, "~> 0.6"},

# Phoenix Generated
................................
]
end

記得跑一下

$ mix deps.get
Resolving Hex dependencies...
Dependency resolution completed:
................................

啟動一下server確認一下沒有問題

$ mix phx.server................................Generated arc app
==> image_upload_s3_demo
Compiling 13 files (.ex)
Generated image_upload_s3_demo app
[info] Running ImageUploadS3DemoWeb.Endpoint with cowboy 2.7.0 at 0.0.0.0:4000 (http)
[info] Access ImageUploadS3DemoWeb.Endpoint at http://localhost:4000
................................

看起來沒有error,但有兩個warning的抱怨,這兩個先跳過它沒有關係;到目前的source code

AWS S3 設定

選擇你熟悉的方式到S3去新增一個bucket,可以用awscli或者是AWS Console UI;這個部分在這裡只是為了Demo所設定,細節內容請一定要自己調整

  • Bucket name: image-upload-s3-demo
  • Block public access: 把Block all public access關閉(Off)
  • Bucket policy: 下面的IAM-ID以及username改成你自己的
{
"Version": "2012-10-17",
"Id": "Policy1566969134462",
"Statement": [
{
"Sid": "Stmt1566969080013",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::IAM-ID:user/username"
},
"Action": "s3:*",
"Resource": "arn:aws:s3:::image-upload-s3-demo/*"
}
]
}
  • CORS configuration: 我們設定GET、POST、PUT、DELETE都可以跨域請求;這個可以照個人需求調整
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

專案Config

這個部分當初花了我不少時間,google了好幾個小時去解決一些常見的問題

  • 上傳完圖片之後出現HTTP 403,無法顯示圖片
  • 產生出來的網址與s3實際上publish url不同,驗證圖片網址的時候不直覺

這個設定是目前我可以直接套用,而且符合我自己debug習慣的設定!另外我知道region出現兩次了,這個也是另一個坑啊!另外這個例子裡我是放在config裡共用,如果有dev/prod/test的環境差別的考量,請自己修改!

config設定

# config/config.exsconfig :ex_aws,
access_key_id: "YOUR_ACCESS_KEY",
secret_access_key: "YOUR_SECRET_KEY",
region: "ap-northeast-1",
s3: [
scheme: "https://",
host: "s3-ap-northeast-1.amazonaws.com",
region: "ap-northeast-1"
]

access_key_id: 自己IAM裡,你可以找到這組

secret_access_key: 填你自己的secret key

region: 我是放在東京區,你可以自己改成你bucket所在的區域;如果你不熟這部分的話,就照抄吧!我的習慣是dev、prod、test會在同一個region但會是不同的bucket

dev設定

# config/dev.exsconfig :arc,  
asset_host: "https://image-upload-s3-demo.s3-ap-northeast-1.amazonaws.com",
storage: Arc.Storage.S3,
virtual_host: true,
bucket: "image-upload-s3-demo"

上面設定的asset_host跟bucket會根據你所創建的bucket名稱不同而不同

到目前的source code

DB Migration

上一篇一樣,就不說明了;到目前的source code

產生Arc設定檔

$ mix arc.g image_uploader
* creating lib/image_upload_s3_demo_web/uploaders/image_uploader.ex

我們需要修改一下,這個範例我只實作了幾個functions,其他還有很多方便的功能,請參考Arc說明

# image_upload_s3_demo_web/uploaders/image_uploader.exdefmodule ImageUploadS3Demo.ImageUploader do
use Arc.Definition
@versions [:original]
@extension_whitelist ~w(.jpg .jpeg .gif .png .mov)
@acl :public_read
def validate({file, _}) do
file_extension = file.file_name |> Path.extname() |> String.downcase()
Enum.member?(@extension_whitelist, file_extension)
end
def filename(version, {file, scope}) do
file_name = Path.basename(file.file_name, Path.extname(file.file_name))
"#{scope.id}_#{version}_#{file_name}"
end
def storage_dir(_version, {_file, _user}) do
"images/"
end
def s3_object_headers(_version, {file, _scope}) do
[
content_type: MIME.from_path(file.file_name),
cache_control: "max-age=0,public"
]
end
end

scope.id: 會傳進一個這張圖片的所屬物;例如說product.id或者是user.id…等;基本上只要map裡有id這個key就會被拿來用

version: 因為Arc可以允許我們上傳圖片時,產生original的同時製作thumb兩個檔案(開啟縮圖會需要實作 transform/2);換言之這裡的檔名會依照呼叫時傳入的atom key而給出不同的url回傳

file_name: 上傳檔案的原始名稱

storage_dir/2: 這個會產生在你的S3 bucket裡面,例如想把圖片分成原圖/縮圖,底下再依照每個user.id去長的檔案結構設定:

def storage_dir(version, {_file, user}) do
"images/avatars/#{version}/#{user.id}"
end

新增routes

把這行加到 ”/” scope裡,讓phoenix產生出全部預設的路徑來吧

resources "/images", ImageController

上傳圖片

先把context的create_image/1改寫;這邊有幾行莫名奇妙的東西,我來說明一下

user = %{id: 1}

會做這個假資料的原因是為了image_uploader需要我們喂給它有id的結構(上面說明裡的scope.id)

{:ok, filename} = ...

當我們使用uplaoder.store之後,它會回傳tuple,這裡是直接簡化掉了,請依照情況去做pattern matching是比較理想的做法

url = ImageUploader.url({filename, user})

這裡我們透過uploader把剛剛上傳檔案的S3 URL回傳給我們,我們才能拿來寫在DB的path欄位裡

# image_upload_s3_demo/image_manager.exalias ImageUploadS3Demo.ImageUploaderdef create_image(attrs \\ %{}) do
user = %{id: 1}
{:ok, filename} =
{attrs, user}
|> ImageUploader.store()
url = ImageUploader.url({filename, user}) %Image{}
|> Image.changeset(%{path: url})
|> Repo.insert()
end

然後是controller的index/2, new/2, create/2

# image_upload_s3_demo_web/controllers/image_controller.exdefmodule ImageUploadS3DemoWeb.ImageController do
use ImageUploadS3DemoWeb, :controller
alias ImageUploadS3Demo.ImageManager def index(conn, _params) do
images = ImageManager.list_images()
render(conn, "index.html", images: images)
end
def new(conn, _) do
changeset = ImageManager.change_image()
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"image" => %{"path" => image_params}}) do
case ImageManager.create_image(image_params) do
{:ok, _image} ->
conn
|> put_flash(:info, "圖片上傳成功")
|> redirect(to: Routes.image_path(conn, :index))
{:error, changeset} ->
conn
|> put_flash(:error, "圖片上傳失敗")
|> render("new.html", changeset: changeset)
end
end
end

Template有2個頁面

  • index: 圖片顯示頁面
# image_upload_s3_demo_web/templates/image/index.html.eex<h4><%= link "上傳圖片", to: Routes.image_path(@conn, :new) %></h4><%= for image <- @images do %>
<img src="<%= image.path %>"><br>
<% end %>
  • new: 圖片上傳頁面
# image_upload_s3_demo_web/templates/image/new.html.eex<h2>上傳新圖片</h2>
<%= form_for @changeset, Routes.image_path(@conn, :create),
[multipart: true], fn f -> %>
<div class="form-group\">
<label>請選擇一張圖片</label>
<%= file_input f, :path %>
<%= error_tag f, :path %>
</div>
<div class=”form-group”>
<%= submit "上傳", class: "btn btn-default" %>
</div>
<% end %>

View

# image_upload_s3_demo_web/view/image_view.exdefmodule ImageUploadS3DemoWeb.ImageView do
use ImageUploadS3DemoWeb, :view
end

測試一下

啟動local server,連到 http://localhost:4000/images,上傳測試看看,成功了!

Elixir / Phoenix 圖片上傳成功
圖片上傳成功並且顯示

到目前的source code

--

--

Deniel Chiang

Decades of yrs experience in software developing. Started from J2EE 1.4/EJB, currently doing Elixir developing for my own consulting workshop.