elm, haskell

HaskellとElmでWeb開発環境を作る

以前色々調べながら試していたんですが、久しぶりにまたやってみようと思ったら何も覚えていなかったので、以前作ったソースコードを見ながら、同じような環境を作ってみます。バックエンドはHaskell servantで、フロントエンドはElmで、という構成です。

以下のソースコードは、githubに置いてあります。とりあえず動かしたい場合はここからクローンして動かしてください。

私の環境

  • Haskell stack v1.9.3
  • node v11.15.0
  • npm v6.7.0
  • yarn 1.16.0

※ 最初に、node v12.3.1とnpm v6.9.0でやろうとしたら、yarn addでreference error: primordialみたいなエラーが出て、調べるたところこのnodeバージョンだとうまくいかないようだったのでv11.15.0に下げました。

プロジェクト作成 – バックエンド

Haskell stackを使います。テンプレートにservantがあるので、これを使います。そのうちservant dockerも使ってみたいですね。実際にデプロイするときにはこっちの方が運用しやすそうな気がします。ちなみに、作るのはノートアプリとしておきます。resolverは別に指定しなくても良いのですが、以前作った環境に合わせて置くために、ここではlts-13.1にします。

stack new note servant --resolver=lts-13.1

フロントエンド

今作ったプロジェクト下にフロントエンド用のディレクトリを作ります。そして、elm v0.19をインストールします。私は全てローカルにインストールします。

cd note
mkdir frontend
yarn init # とりあえず全部デフォルトのままエンター
yarn add elm@0.19.0-no-deps

# 確認
yarn elm --version

それから、webpackと、webpackでelmを使うためのパッケージなど諸々をインストールします。

yarn add webpack webpack-dev-server elm-webpack-loader file-loader style-loader css-loader url-loader sass-loader
yarn add ace-css@1.1 font-awesome@4
yarn add -D webpack-cli
yarn add -D @webpack-cli/init
yarn add -D html-webpack-plugin uglifyjs-webpack-plugin

Elmのパッケージインストール

cd frontend/
yarn elm init # y
yarn elm install elm/url # y
yarn elm install Fresheyeball/elm-return # y

webpack.config.jsの作成

note/frontend/webpack.config.jsです。Elmのロード方法なども書きます。面倒なのでコピペで。

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
    module: {
        rules: [
            {
                test: /\.css$/,

                use: [
                    {
                        loader: 'style-loader',

                        options: {
                            sourceMap: true
                        }
                    },
                    {
                        loader: 'css-loader'
                    }
                ]
            },
            {
                test: /\.scss/,
                use: [
                    {
                        // output to link tag
                        loader: 'style-loader'
                    },
                    // bundle css
                    {
                        loader: 'css-loader',
                        options: {
                            // prohibit `url()` in css`
                            url: false,
                            // use source map
                            sourceMap: true,
                            // 0 => no loaders
                            // 1 => postcss-loader
                            // 2 => postcss-loader, sass-loader
                            importLoaders: 2
                        }
                    },
                    {
                        loader: 'sass-loader',
                        options: {
                            sourceMap: true
                        }
                    }
                ]
            },
            {
                test: /\.elm$/,
                exclude: [
                    /elm_stuff/,
                    /node_modules/
                ],
                use: [
                    {
                        loader: 'elm-webpack-loader?verbose=true'
                    }
                ]
            },
            {
              test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
              loader: 'url-loader?limit=10000&mimetype=application/font-woff',
            },
            {
              test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
              loader: 'file-loader',
            }
        ],
        noParse: /\.elm$/
    },

    entry: {
        app: './src/index.js'
    },

    output: {
        filename: '[name].[chunkHash]js',
        path: path.resolve(__dirname, '../www/dist')
    },

    mode: 'development',
    plugins: [
        new UglifyJSPlugin(),
        new HtmlWebpackPlugin({
            template: './src/index.html',
            inject: 'body',
            filename: 'index.html'
        })
    ],

    optimization: {
        splitChunks: {
            cacheGroups: {
                vendors: {
                    priority: -10,
                    test: /[\\/]node_modules[\\/]/
                }
            },

            chunks: 'async',
            minChunks: 1,
            minSize: 30000,
            name: true
        }
    },
    devServer: {
        inline: true,
        stats: { colors: true },
    }
};

最小限のアプリを書いて実行してみる

note/frontend/src/index.htmlです。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Note app by Servant and Elm</title>
    </head>
    <body>
        <div id="main"></div>
    </body>
</html>

note/frontend/src/index.jsです。Elmはjsにコンパイルされるので、そのメイン関数をインポートしてid=”main”の要素に埋め込みます。

'use strict';

require('ace-css/css/ace.css');
require('font-awesome/css/font-awesome.css');

import './style.scss'

var {Elm} = require('./Main.elm');
var mountNode = document.getElementById('main');

var app = Elm.Main.init({node: mountNode});

note/frontend/src/main.Elmです。ようやくelmです。あとでモジュールを分けていきますが、まずは全部mainに書いてしまいます。

module Main exposing (..)

import Browser exposing (application, Document)
import Browser.Navigation as Nav
import Html exposing (..)
import Return exposing (Return)
import Url

type alias Model =
    String

type Msg =
    NoOp

init : () -> Url.Url -> Nav.Key -> (Model, Cmd Msg)
init flags url key =
    ("Hello from Elm.", Cmd.none)

view : Model -> Document Msg
view model =
    { title = "Note"
    , body = viewMain model
    }

viewMain : Model -> List (Html Msg)
viewMain model =
    [ div []
          [ text model ]
    ]

update : Msg -> Model -> Return Msg Model
update msg model =
    case msg of
        NoOp -> (model, Cmd.none)

main : Program () Model Msg
main =
    application
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        , onUrlChange = \_ -> NoOp
        , onUrlRequest = \_ -> NoOp
        }

あと、note/frontend/src/style.scssを作っておきます。

touch src/style.scss

これで動かせるはずです。frontendディレクトリ直下で以下を実行します。

# note/frontend直下で
yarn webpack

# うまく行ったら以下でdevサーバー立ち上げ
yarn webpack-dev-server --port 3000

http:/localhost:3000にアクセスすると、Hello from Elmと表示されるはずです。これでもうなんでもできます。

おまけ

以下のようにすると、簡単に立ち上げられるようになります。

frontend/package.jsonに以下を追記します。

  "scripts": {
    "build": "webpack",
    "client": "webpack-dev-server --port 3000",
    "start": "nf start"
  }

それから、frontend/Procfileを作成して、中に以下の1行を書きます。

client: yarn client

これで、yarn startでビルドとdevサーバー立ち上げを実行できるようになりました。Haskellの方はプロジェクトを作っただけでまだ何も書いてないのにだいぶ長くなってしまいました。どうしましょう。ただのElm+webpack環境構築の記事になってしまいました。

バックエンド

ようやくHaskellに戻ってきました。今Elmで作った静的ページをServantでたてたWebサーバーでホストします。Servantを使う本当の目的はこのように静的ページを扱うことではなく、HaskellバックエンドでWebAPIを提供することだと思いますが、この記事はHaskell+ElmのWeb開発環境を作るところまでなので、とりあえず何か表示できたらOKとします。

以下のようにnote/src/App.hsを作成します。

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}

module App where

import qualified Data.ByteString.Lazy as B
import Network.HTTP.Media ((//), (/:))
import Servant
import Servant.Server (serve)
import System.FilePath ((</>))

import Config.Config (_distDir_, _entry_)


data HTML

instance Accept HTML where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

instance MimeRender HTML B.ByteString where
    mimeRender _ bs = bs

type API =
    Get '[HTML] B.ByteString
    :<|> Raw

api :: Proxy API
api = Proxy

root :: String
root = _distDir_

server :: IO (Server API)
server = do
    indexHtml <- B.readFile $ root </> _entry_
    let server' =
            pure indexHtml
            :<|> serveDirectoryWebApp root
    return server'

app :: IO Application
app = serve api <$> server

ソース内で、Config.Configをインポートしていますが、単純にディレクトリやファイルパスを定義しているだけです。note/src/Config/Config.hsです。

{-# LANGUAGE OverloadedStrings #-}

module Config.Config where

{-
 - frontend
 -}

_distDir_ = "www/dist"
_entry_ = "index.html"

メインは以下のようになります。note/app/Main.hsです。

module Main where

import Network.Wai.Handler.Warp
import System.IO (hPutStrLn, stderr)
import App

main :: IO ()
main = do
    let
        port = 3000
        settings =
            setPort port $
            setBeforeMainLoop (hPutStrLn stderr
                ("Listening on port " ++ show port ++ "..."))
            defaultSettings
    runSettings settings =<< app

note.cabalを以下のように変更する必要があります。モジュールファイル名の追加と、依存パッケージ名の追加です。以下は変更部分のlibraryのところだけ記載しています。

library
  hs-source-dirs:      src
  exposed-modules:     App
                     , Config.Config
  build-depends:       base >= 4.7 && < 5
                     , aeson
                     , bytestring
                     , filepath
                     , http-media
                     , servant
                     , servant-server
                     , wai
                     , warp
  default-language:    Haskell2010

これでビルドすれば動くはずです。フロントエンドとバックエンドをそれぞれビルドし、最後にHaskellのプログラム(Servantのwebサーバー)を起動します。

cd frontend
yarn webpack
cd ../
stack build
stack exec note-exe

http://localhost:3000/にアクセスすれば、webpack-dev-serverでやった時と同じ画面が出てくるはずです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です