Elixir / Phoenix — 上傳圖片到本地端 (with Arc)

Deniel Chiang
19 min readFeb 24, 2020

--

當我們做Web Apps的時候,很常會有圖片上傳的功能,我們來試試看Elixir / Phoenix 怎麼用 Arc / Arc.Ecto實做 。

Photo by Henry & Co. on Unsplash

環境版本

$ 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_demo
* creating image_upload_demo/config/config.exs
* creating image_upload_demo/config/dev.exs
* creating image_upload_demo/config/prod.exs
................................
Fetch and install dependencies? [Yn] y

或者是 clone 一個回來 — master是還沒做過變更的

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

接著來產生資料庫:

$ cd image_upload_demo$ mix ecto.create
Compiling 13 files (.ex)
Generated image_upload_demo app
The database for ImageUploadDemo.Repo has been created

Schema的話,我們至少要有一個欄位是存放圖片檔案路徑的,所以我們來產生migrate檔:

$ mix phx.gen.context ImageManager Image images path:string
* creating lib/image_upload_demo/image_manager/image.ex
................................

執行migrate

$ mix ecto.migrate
Compiling 2 files (.ex)
Generated image_upload_demo app

產生後的Source Code: Git branch — create_migration

接下來是controller:

# image_upload_demo_web/controllers/image_controller.exdefmodule ImageUploadDemoWeb.ImageController do
use ImageUploadDemoWeb, :controller
alias ImageUploadDemo.ImageManager def index(conn, _params) do
render(conn, "index.html")
end

def new(conn, _) do
changeset = ImageManager.change_image()
render(conn, "new.html", changeset: changeset)
end

def create(conn, %{"image" => image_params}) do
IO.inspect image_params
end
end

新增一個function到context:

# image_upload_demo/image_manager.exdefmodule ImageUploadDemo.ImageManager do  ................................  def change_image, do: %Image{} |> Image.changeset(%{})end

產生後的Source Code: Git branch — create_controller

view:

# image_upload_demo_web/views/image_view.exdefmodule ImageUploadDemoWeb.ImageView do
use ImageUploadDemoWeb, :view
end

產生後的Source Code: Git branch — create_view

routes:

# image_upoload_demo_web/router.ex................................

scope "/", ImageUploadDemoWeb do
pipe_through :browser
get "/", PageController, :index resources "/images", ImageController
end
................................

產生後的Source Code: Git branch — create_routes

來追加一個可以到上傳圖片的連結到我們的index template裡面吧

# image_upload_demo_web/templates/image/index.html.eex<h4><%= link "上傳圖片", to: Routes.image_path(@conn, :new) %></h4>

上傳圖片頁面

# image_upload_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, :image %>
<%= error_tag f, :image %>
</div>
<div class=”form-group”>
<%= submit "上傳", class: "btn btn-default" %>
</div>
<% end %>

我們可以先啟動並連結到 http://localhost:4000/images 看一下目前的UI,你會發現我們已經有連結到上傳圖片,並且上傳圖片裡也有圖片上傳的基本UI

Elixir / Phoenix 圖片上傳連結
圖片上傳連結
Elixir / Phoenix 圖片上傳UI
圖片上傳頁面UI

而且如果你實際上上傳一張圖片,你可以看到剛剛印出來的image_params內容長這樣

%{
"path" => %Plug.Upload{
content_type: "image/png",
filename: "Screen Shot 2020-02-22 at 14.10.58.png",
path: "/var/folders/rm/1z9992951mj6kwj7bc9vwxw40000gn/T//plug-1582/multipart-1582517946-261467886807514-1"
}
}

我們已經知道檔案類型、檔名、還有暫存資料夾的路徑了

產生後的Source Code: Git branch — add_templates

接下來是重點了,我們要開始使用arc了;我們會用到下面兩樣東西:

  1. arc主程式
  2. arc_etco — 整合 changeset;可以產生有版號的URL

把arc加進mix.exs

# mix.exs................................defp deps do
[
{:arc, "~> 0.11.0"},
{:arc_ecto, "~> 0.11.3"}
]
end
................................

然後跑一下deps.get,可以發現新增了一堆會用到的dep libs

$ mix deps.get
Resolving Hex dependencies...
Dependency resolution completed:
Unchanged:
................................New:
arc 0.11.0
arc_ecto 0.11.3
certifi 2.5.1
hackney 1.15.2
idna 6.0.0
metrics 1.0.1
mimerl 1.2.0
parse_trans 3.3.0
ssl_verify_fun 1.1.5
unicode_util_compat 0.4.1
* Getting arc (Hex package)
* Getting arc_ecto (Hex package)
* Getting hackney (Hex package)
* Getting certifi (Hex package)
* Getting idna (Hex package)
* Getting metrics (Hex package)
* Getting mimerl (Hex package)
* Getting ssl_verify_fun (Hex package)
* Getting unicode_util_compat (Hex package)
* Getting parse_trans (Hex package)

接下來我們用arc來產生一隻需要我們客製化的設定檔案,我們就叫它image_uploader好了

$ mix arc.g image_uploader................................
Generated arc app
==> image_upload_demo
* creating lib/image_upload_demo_web/uploaders/image_uploader.ex

它會有一些warning,大多是在抱怨沒有S3的設定之類的,我們先無視這些抱怨,因為我們這篇先講上傳到server端,而非AWS S3

如果我們有先設定local端的設定檔的話,就不會有抱怨了;設定檔在這裡我是放到config/config.exs,你也可以視deployment情況來組合;例如:dev是上傳到local端,test是上傳到AWS S3 testing bucket,prod則是到AWS S3 production bucket

# config/config.exsconfig :arc,
storage: Arc.Storage.Local

回到source code你會發現,它幫你產生了一隻需要你customize的image_upload_demo_web/uploaders/image_uploader.ex

因為我們會用到arc_etco,所以先打開第五行的註解,把arc_etco引用吧!至於其他細部的功能還有很多,圖片轉檔、做縮圖、尺寸縮放、預設上傳圖片路徑修改…等,都可以在arc找到說明

# image_upload_demo_web/uploaders/image_uploader.exdefmodule ImageUploadDemo.ImageUploader do
use Arc.Definition
# Include ecto support (requires package arc_ecto installed):
use Arc.Ecto.Definition
@versions [:original]# To add a thumbnail version:
# @versions [:original, :thumb]
# Override the bucket on a per definition basis:
# def bucket do
# :custom_bucket_name
# end
# Whitelist file extensions:
# def validate({file, _}) do
# ~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
# end
# Define a thumbnail transformation:
# def transform(:thumb, _) do
# {:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
# end
# Override the persisted filenames:
# def filename(version, _) do
# version
# end
# Override the storage directory:
# def storage_dir(version, {file, scope}) do
# "uploads/user/avatars/#{scope.id}"
# end
# Provide a default URL if there hasn't been a file uploaded
# def default_url(version, scope) do
# "/images/avatars/default_#{version}.png"
# end
# Specify custom headers for s3 objects
# Available options are [:cache_control, :content_disposition,
# :content_encoding, :content_length, :content_type,
# :expect, :expires, :storage_class, :website_redirect_location]
#
# def s3_object_headers(version, {file, scope}) do
# [content_type: MIME.from_path(file.file_name)]
# end
end

接下來,我們要修改Model;讓欄位格式apply到我們產生的uploader.Type,這樣arc_ecto才會作用;記得cast_attachments apply的順序很重要,要在cast/3的後面喔!!!

# image_upload_demo/image_manager/image.exdefmodule ImageUploadDemo.ImageManager.Image do
use Ecto.Schema
use Arc.Ecto.Schema
import Ecto.Changeset
schema "images" do
field :path, ImageUploadDemo.ImageUploader.Type
timestamps()
end
@doc false
def changeset(image, attrs) do
image
|> cast(attrs, [:path])
|> cast_attachments(attrs, [:path])
|> validate_required([:path])
end
end

來補一下controller的create function內容

# image_upload_demo_web/controllers/image_controller.ex................................def create(conn, %{"image" => 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
................................

完成後我們可以啟動server,可以看到上傳的圖片路徑+版號已經被我們insert到資料庫裡的欄位了

[info] Sent 200 in 2ms
[info] POST /images
[debug] Processing with ImageUploadDemoWeb.ImageController.create/2
Parameters: %{"_csrf_token" => "fyo9ECVUNBd4UnoaMWNAbBUaFy0qPl8JKzvrBcWQU69Ha0uZRlYhFp6B", "image" => %{"path" => %Plug.Upload{content_type: "image/png", filename: "Screen Shot 2020-02-22 at 14.10.58.png", path: "/var/folders/rm/1z9992951mj6kwj7bc9vwxw40000gn/T//plug-1582/multipart-1582529482-675347977397327-4"}}}
Pipelines: [:browser]
[debug] QUERY OK db=24.1ms decode=4.7ms queue=4.5ms idle=9549.6ms
INSERT INTO "images" ("path","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Screen Shot 2020-02-22 at 14.10.58.png?63749748682", ~N[2020-02-24 07:31:22], ~N[2020-02-24 07:31:22]]

記得把/uploads/加到你的.gitignore,否則repostitory上就會有數不完的圖片檔了…

產生後的Source Code: Git branch — use_arc

剛剛新增的圖片我們需要把它顯示出來,所以我們分別在

controller:

# image_upload_demo_web/controller/image_controller.exdef index(conn, _params) do
images = ImageManager.list_images()
render(conn, "index.html", images: images)
end
................................

template:

# image_upload_demo_web/templates/image/index.html.eex<h4><%= link "上傳圖片", to: Routes.image_path(@conn, :new) %></h4><%= for image <- @images do %>
<img src="<%= ImageUploadDemoWeb.ImageUploader.url({image.path, image})%>"><br>
<% end %>

然後啟動server,你會發現圖片沒有顯示出來

Elixir / Phoenix 圖片沒有顯示
圖片沒有顯示

因為我們還沒有設定endpoin的資料夾;目前我們上傳好的圖片是存放在專案結構的uploads底下的,而phoenix預設是沒有開放這個資料夾會被publish出來的;我們來新增一下這個資料夾的publishing

# image_upload_demo_web/endpoint.ex................................# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug(Plug.Static,
at: "/",
from: :image_upload_demo,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
)
plug(Plug.Static, at: "/uploads", from: Path.expand('./uploads'), gzip: false)................................

再來看一下 http://localhost:4000/images,這次成功顯示出來了,並且成功訊息也都正確無誤

圖片上傳清單成功

耶~我們成功地做出上傳圖片了!

最終完成的Source Code: Git branch — list_images

我們會在下篇繼續講該怎麼利用AWS S3 bucket來做上傳圖片的storage

--

--

Deniel Chiang

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