Создаем облако на Elixir

Вступление

Разрабатываемое с вами облако нельзя назвать production ready. Мы его напишем строго в учебных целях, чтобы понять, что любой может написать такую сложную на первый взгляд инфраструктурную систему. Если требуется мощное решение, можно воспользоваться NextCloud или поднять FreeNAS. 

Облачные сервисы уже давно стали неотъемлемой частью нашей жизни. На данный момент существует большое количество сервисов от разных компаний. Так давайте разберемся в принципах работы простейшего облачного сервиса, подтянем навыки проектирования систем. Данный проект можно постоянно развивать на протяжении длительного времени, и может стать отличным pet project. Созданное облако может пригодится для управления домашними файлами, достаточно его развернуть в локальной сети и с легкостью получать доступ к файлам с разных устройств.

Самое время определить задачи, которые нам необходимо решить, чтобы реализовать систему.

Определение задач

Система должна соответствовать минимальному CRUD приложению:

  • Просматривать содержимое файловой системы облачной папки;

  • Реагировать на изменения в файловой системе;

  • Загружать файлы в облако;

  • Редактировать файлы (переименовывать или перемещать);

  • Искать по названию;

  • Постараться сделать это все максимально оптимально.

Для решения этих задач логично выбрать клиент-серверную архитектуру. Реализация наших задач будет состоять из фронт и бек части. 

Начнем с бекенда.

Язык: Elixir

Технология: Phoenix

Почему Elixir, а не %любой_другой_язык_для_описания_API%?

Я решил выбрать Elixir из-за лёгкого вхождения в мир функционального программирования, схожести языковых конструкций с Python, неплохой скорости работы и быстроты прототипирования.

Сравнение API фреймворков по производительности.

Сравнение фреймворков для написания системы

ASP.NET (C#)

  • Быстрый

  • Обладает сильной строгой типизацией;

  • Для учебного проекта разработка на нем пойдет слишком долго.

Flask (Python)

  • Из-за особенностей Python, работает не слишком шустро;

  • Скорость  написания кода высокая.

Phoenix (Elixir)

  • Относительно Flask быстро работает;

  • Похож на Python, писать просто;

  • Для первого функционального языка достаточно прост в изучении.

 Cowboy или Plug (Elixir)

  • Предоставляет слишком много свободы и не диктует разработчику архитектуру, но эта система была моим первым опытом работы на Elixir, поэтому было принято решение взять уже готовый инструмент.

Yesod (Haskell)

  • Обладает сильной строгой типизацией;

  • Для данного проекта разработка пойдет слишком долго, т.к. я знаю только базовые элементы синтаксиса.

В статье далее я не буду подробно рассказывать, что такое монады или объяснять другие сложные и не очень особенности функционального программирования, но мы вместе с вами попробуем разобраться в интересных функциональных подходах, вроде пайплайна, композиции или каррирования, которые нам полезно знать для реализации приложения на функциональном языке.

В приведенном ниже коде я буду описывать в основном ядро системы, и, возможно, некоторые детали коммуникации с клиентом, если это представляет интерес. Важно понимать, что большинство рутинного кода будет опущено, чтобы не перепечатывать репозиторий. 

Жизненный путь запроса в Phoenix
Жизненный путь запроса в Phoenix

Просмотр файлов

Backend

На клиенте необходимо получить информацию о файлах в текущей директории, поэтому реализуем серверные эндпойнты для решения этой задачи.

Создадим новый модуль. Для легкости восприятия, его можно считать пространством имен.

defmodule DirectoryTreeHelper do

Создадим тип для нашего представления файла. Для работы с ним на фронтенде нам бы хотелось знать, является ли этот файл папкой хотя все - это файл. Без поля filename мы бы могли обойтись, но тогда нам бы пришлось вызывать path.basename (или аналог) на фронтенде для определения имени каждого файла. children нам будет необходим, если мы будем рекурсивно обходить директорию, но на начальном этапе это нам не нужно, пусть в нем всегда лежит nil.

  @type file :: %{
          :isFolder => boolean(),
          :filename => String.t(),
          :path => String.t(),
          :children => nil | [file()],
          :info => %{:size => non_neg_integer()}
        }

Приступим к написанию алгоритма. Будем отдавать файлы страницами, используя таким образом механизм виртуализации, потому что это даем нам такое преимущество как то, что пользователю не нужно ожидать загрузки информации о всех файлах, достаточно 2 * количество файлов на экране в данный момент.

Виртуализация

Виртуализация выгодна, когда у нас простые легкие ноды, как сейчас.

@spec list_all(String.t(), String.t(), integer(), integer(), boolean()) :: file()  
  def list_all(filepath, page, page_size, is_recursive) do
      File.ls!(filepath)
        |> Enum.chunk_every(page_size)
        |> Enum.at(page, [])
        |> Enum.map(fn file -> iterator(filepath, file, page, page_size, is_recursive) end)
  end
А что делает оператор |> ?

Это оператор пайплайна, он делает композицию функций, т.е. передает результат выполнения выражения первым параметром в следующее выражение.

defmodule Test do
  def func(arg1, arg2) do
    arg1 + arg2
  end

  def func2(sum, arg3) do
    sum * arg3
  end
end

Test.func2(Test.func(1,2), 3) |> IO.puts # 9
# аналогично строке ниже
Test.func(1, 2) |> Test.func2(3)  |> IO.puts # 9

Test.func(1, 5) |> Test.func2(12) |> to_string |> String.reverse  |> IO.puts # 27

Если явных аргументов у функции не осталось, то скобки для вызова функции можно опустить.

Вариант в более структурном стиле, без применения пайплайна
  def list_all(filepath, page, page_size, is_recursive) do
    files = File.ls!(filepath)
    pages = Enum.chunk_every(files, page_size)
    all_pages = Enum.at(page, [])

      Enum.map(current_page, fn filename ->
       get_file(filepath, filename, page, page_size, is_recursive)
      end)

@spec - это определения типа функции, в Haskell оно оформляется похожим образом.

В этой функции мы вызвали iterator get, предлагаю его определить сейчас.

  @spec get_file(String.t(), String.t(), integer(), integer(), boolean()) :: file()
  def get_file(filepath, filename, page, page_size, is_recursive) do
    full_path = "#{filepath}/#{filename}"
    is_folder = File.dir?(full_path)
    file_stat = File.lstat!(full_path)

    children = if is_recursive and is_folder, do: list_all(full_path, page, page_size, is_recursive), else: nil

    %{
      :isFolder => is_folder,
      :filename => filename,
      :path => fullPath,
      :info => %{
        :size => fileStat.size
      },
      :children => children
    }
  end

Просмотр файлов

Frontend

Фронтенд было решено разрабатывать на React + TypeScript. Для менеджера состояния использую MobX.

Структура DTO для контроллера.

Название поля

Тип данных

Значение по умолчанию

Пояснение

directory

string?

.

Относительный путь до папки, где корень - наша dataDir с файлами

page

number?

0

Если требуется пагинация, и мы дробим список файлов на страницы, то мы передаем сюда страницу.

page_size

number?

Количество файлов в директории

Размер страницы, по умолчанию равен длине списка.

is_recursive

boolean?

false

Если требуется получить не только текущую директорию в результате запроса, а еще и все вложенные, то можно подать сюда true. Может пригодиться, если захотим интегрировать SotrableTree.

При каждом переходе во внутреннюю директорию нужно менять url, в зависимости от которого нужно делать запросы за информацией о файлах в этой директории. Для этого необходимо заполнить поле directory.

Напишем основной store с содержимым директорий.

export class FilesListFactoryStore {
    public currentDirectory = '';
 
    constructor() {
        fileChecker.createChannel(() => this.getFiles()); # тут мы  отреагируем на изменение в файловой системе хоста
    }
 
    public setCurrentDirectory = (currentRoute: ClientRouteType) => (locationPathname: string) => {
        this.currentDirectory = locationPathname.replace(currentRoute, '');
        this.getFiles();
    };
 
    public getFiles = async (): Promise<void> => {
       # логика по запросу файлов с бекенда	
    };

 

Запуск приложения.

Phoenix предлагает свой способ поднятия фронтенд сервиса. Это дает возможность поднять за одну команду два сервиса. 

config.dev.exs

config :elCloud, ElCloudWeb.Endpoint,
  http: [port: 4000],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [
    node: [
      "node_modules/webpack/bin/webpack.js",
      "--mode",
      "development",
      "--watch-stdin",
      "--env.NODE_ENV=dev",
      cd: Path.expand("../assets", __DIR__)
    ]
  ]

Если запускать облако только на Linux, то можно указать скрипты из package.json, но на Windows будет ошибка с разрешениями.

Реакция на изменение в файловой системе

Теперь нам нужно сделать реакцию на изменение в файловой системе. Для этого необходимо определить FileWatcher, который будет смотреть за файлами, если изменение произойдет, он отправит сообщение всем подписанным клиентам. В нашем случае подписчиком будет выступать канал (аналог Stream из Dart), открытый по протоколу WebSocket. В данной реализации мы можем и использовать Server Sent Events, но предпочтем двухсторонний канал с заделом на будущее. ElCloudWeb.FileChannel - это модуль, в котором мы override’им некоторые методы, вроде join - подсоединение нового пользователя к каналу, send_message - отправка сообщения в канал, handle_info - поймает события с типом action.

Этот модуль будет обрабатывать подключения к нашему бекенду и отвечать за отправку сообщений всем подписанным клиентам.

defmodule ElCloudWeb.FileChannel do
  use Phoenix.Channel

  def join("example", payload, socket) do
    {:ok, socket}
  end

  def send_message(payload) do
    Phoenix.PubSub.broadcast(ElCloud.PubSub, "example", %{
      type: "action",
      payload: %{data: payload}
    })
  end

  def handle_info(%{type: "action"} = info, socket) do
    push(socket, "action", info)
    {:noreply, socket}
  end
end

На клиенте нужно создать инстанс сокета, подключиться к нужному каналу, и подписаться на получение сообщений об изменении.

import {Socket} from "phoenix"

export class FileSystemChecker {
  private _socket: Socket
  constructor() {
    this._socket = new Socket('/socket')
    this._socket.connect()
  }

  public createChannel = (onMessage: () => void) => {
    const channel = this._socket.channel("example", {})
    channel.join()
      .receive("ok", resp => {})
      .receive("error", resp => {})

    channel.onMessage = (ev, payload) => {
      onMessage()
    }

  }

Демонстрация работы функциональности
Демонстрация работы функциональности

Поиск

Для поиска нам нужно всего лишь рекурсивно пройтись по поданной директории, и постараться найти файл. 

 def searchQuery(queryFilename, directory) do
     File.ls!(directory)
      |> Enum.map(fn file -> iterator(directory, file, queryFilename) end)
      |> List.flatten()
      |> Enum.filter(fn file -> String.match?(file.filename, ~r/#{queryFilename}/) end)
  end
 
 
  def recursiveSerchInFolder(directory, filename, queryFilename) do
    fullPath = Path.join(directory, filename)
    isFolder = File.dir?(fullPath)
 
    if isFolder, do: searchQuery(queryFilename, fullPath), else: %{
      :isFolder => isFolder,
      :filename => filename,
      :path => fullPath
    }  
 
  end

К сожалению, это будет работать достаточно долго. Как вариант, чтобы избежать этой проблемы, можно сделать индексацию всей файловой системы. 

Например, это можно реализовать, если завести базу данных, записать данные о файловой структуре в плоском виде. На каждое изменение структуры файловой системы обновлять соответствующие записи в БД. И тогда при поиске не придется каждый раз обращаться к медленной файловой системе, и поиск будет работать значительно быстрее.

Это далеко не самый хороший вариант, но этого уже достаточно для получения ускорения не индексированного поиска.

Скачивание (Download)

Для реализации скачивания файла на фронтенде достаточно внести изменения, создав корректную ссылку на файл и добавив атрибут download на тег ссылки.

 <a href={file.link} download={true} >  

 На бекенде нужно отправлять файл на клиент, исходя из переданного пути до файла.

Plug.Conn.send_file(conn, 200, directory)

Загрузка (Upload)

Для отправки файла на сервер необходимо создать объект FormData, в данном  случае еще важно учесть директорию, куда будем загружать файл.

const formData = new FormData();
formData.append('file', file);
formData.append('directory', directory);

Также нужно выставить заголовок, что мы отправляем multipart содержимое запроса.

headers: {
	'Content-Type': 'multipart/form-data',
	...config?.headers,
},

На сервере же просто ловим путь из temp директории, и копируем файл в директорию с файлами.

fullpath = Path.join([@data_dir, directory, file.filename])
File.cp(file.path, Path.absname(fullpath))  

 

Редактирование

Теперь освоим такой подход как pattern matching, который может под капотом понять, какой тип мы подаем в функцию и вызвать подходящее предопределенное определение.

Таким подходом удобно можно заменить ветвление на один вызов функции и N определений функций, в которых мы положим действие из ветвления.

  if (isOperationSuccess) {
    return successMessage
  } else {
    return errorMessage
  }
operationHandler(isOperationSuccess)

const operationHandler = (success) => successMessage
const operationHandler = (error) => errorMessage

Например, так мы можем переименовать файл.

  def move_file(conn, %{"oldPath" => oldPath, "newPath" => newPath}) do
        File.rename(Path.join(@data_dir, oldPath), Path.join(@data_dir, newPath)) 
      |> send_response(conn)
  end

  def send_response( {:error, :enoent}, conn), do: {:error, :folderNotFound}
  def send_response(:ok, conn), do: render(conn, "show.json", file_storage: :ok)

Несмотря на то, что часто строят пайплайн в другом порядке, передавая вниз по пайпу conn, в этом случае я бы сделал по-другому, и отправлял реальные часто изменяемые данные внутри пайпа.

Удаление

На данной итерации разработки приложения мы не будем прикручивать функциональность корзины,  поэтому удаление будет самой примитивной логикой, мы просто вызовем стандартный метод удаления файла.

File.remove(filepath)

На уровне контроллера можно реализовать это таким образом, используя конвейер.

  def delete(conn, %{"path" => path}) do
    Path.join(@data_dir, path) 
      |> DirectoryTreeHelper.remove_file() 
      |> send_response(conn)
  end

Итог 

Мы:

  • Разработали клиент-серверное приложение на Elixir и React;

  • Поняли, что разрабатывать на функциональном языке не так сложно, как кажется на первый взгляд;

  • Разобрались в простых функциональных конструкциях.

Полный исходный код можно найти в репозитории проекта.