関数型プログラミング超入門

Scala Advent Calendar 2022 - Qiita 3日目の記事です

Scalaオブジェクト指向プログラミング、関数型プログラミングのどちらもできるプログラミング言語ですが、ここでは関数型プログラミングについての基本的なところを説明します。

この記事のサンプルコードは、sbt console を使って実行しています。

1. Referential Transparency (参照透過性)

参照透過性とは、「プログラムの構成要素が同じもの同士は等しい」ということです。

例えば:

  • 1 と 1 は等しい
  • 文字列 abc と文字列 abc は等しい
  • 関数 f(1) と 関数 f(1) は等しい

つまり、変数 a と b に同じ値 (1 や abc や f(1)) を代入しているなら、a と b は等しいということになります。

println(1 == 1)
println("abc" == "abc")
def fn(a: Int) = a * 2;
println(fn(1) == fn(1))

Java になれている場合 "abc" == "abc" に違和感があるかもしれません。しかし Scala の場合には、== は値の等しさを比較するため、"abc".equals("abc") のような書き方は必要ありません。

参照透過性の利点 (An advantage of referential transparency)

参照透過性の利点は、数学的推論が利用できることです。f(1) の計算結果は常に f(1) の計算結果と等しいため、その時の状況によって結果が変わらないため、テストが容易になります。

2. Immutable (不変性)

構造化/手続き型/ 命令型プログラミングでは、次の例のように、変数をミュータブルに使用するのが一般的です。

var sum = 0 // var で定義されるミュータブルな変数
val list = [1, 2, 3]
for (x <- list) sum = sum + x
println(sum)

変数 sum、x の値はプログラムの実行中に値を変化させます。これは、参照透過性の破壊を意味します。

関数型プログラミングでは、イミュータブルな変数を利用することが一般的です。

3. Side Effect (副作用)

関数がファイルやデータベース、ネットワーク等外部との入出力に依存している場合、外部環境の変化により関数の結果が変化することがあります。このような作用を「副作用」と呼びます。副作用もまた参照透過性の破壊に繋がります。

この副作用を伴わずに参照透過性を持つ関数を「純粋な関数」と呼びます。

関数型プログラミングで、副作用を伴う関数を定義する場合、副作用を特定の関数内に隔離することがベストプラクティスになります。

4. Higher-order Function (高階関数)

高階関数とは、簡単にいうと引数に関数を、あるいは関数を返す関数です。

次の例は、高階関数の例です。Option.map() は引数が関数 (Function) です。:

def fn(x: Int) = x * 2
val maybe = Option(3)
println(maybe.map(fn(_)))

5. Map、Filter、Reduce

ここで説明する Map、Filter、Reduce はコレクションやストリームを扱う高階関数を使うパターンです。

  • map(f) は、要素の型を変換する関数が引数になります。元の型 E を変換後の型 F にします。
  • filter(f) は、要素の値を評価して通過させる場合は true、破棄する場合は false を返す関数が引数になります。
  • reduce(f) は、コレクションやストリームの要素の合計値を求めるというような計算に使用します。引数は要素の値を使って集計する関数になります。
val seq = Seq("1", "2", "3", "2", "2", "4")
seq.map(_.toInt).filter(_ == 2).reduce((x, y) => x + y)

6. カリー化

次のような関数があったとします。

def oldSum(x: Int, y: Int) = x + y

この関数をカリー化すると、次のようになります。

def sum(x: Int)(y: Int) = x + y

これによって何が違うかですが、次のようにするとわかります。

sum(1)_
res1: Int => Int = $Lambda$4598/185771598@5c88fc39

つまり sum(1)_ は、fn(y: Int) を返しています。これを利用して、与えられて引数に 2 を足す関数を生み出すことができます。

val twoPlus: Int => Int = sum(2)_
twoPlus(5)
res2: Int = 7

カリー化によって引数を1つとすることで、後で述べる関数合成がしやすくなるという利点もあります。

7. モジュール性

ここでのモジュールは、jar 形式などのライブラリモジュールのことではありません。

さまざまな機能を、小さな扱いやすい単位にソフトウェアを分割することをモジュール化といいます。

モジュールに必要な性質は、個々のモジュールの独立性です。モジュールが独立していれば、他のモジュールの変更による影響を受けにくくなります。関数が参照透過性を持っていれば、関数の結果は引数にのみ依存し、計算結果は他のコードの影響を受けない予測可能な値を返します。

8. 関数合成

複数の関数を引数として、合成した関数を返すことができます。

def sum: List[Int] => Int = _.reduce(_ + _)
def toOne: List[Any] => List[Int] = _.map(_ => 1)
def compose(f: List[Int] => Int, g: List[Any] => List[Int]) = (arg: List[Any]) => f(g(arg))

val list = List("abc", "def", "ghi")
compose(sum, toOne)(list)
res5: Int = 3

9. 遅延評価

遅延評価とは、計算を後回しにして評価する方法です。

遅延評価は無限に続く Stream などを表現することができます。

例えば、正格評価の List(1, 2, 3) では、評価時点でメモリに有限個の要素が確保されます。メモリは有限のリソースですので、無限個の要素からなる List を表現できないことは明確です。

遅延評価を用いる場合、処理時点で要素を評価することになります。つまりメモリにも評価時点で必要な個数の要素があれば良いことになります。

10. クロージャ

def makeCounter = {
    var count = 0
    () => {
        count = count + 1
        count
    }
}

val c = makeCounter
c()
res0: Int = 1
c()
res1: Int = 2

クロージャを使用することで、関数外から参照できない変数を定義することができます。

11. 畳み込み (Fold)

val list = List(1, 2, 3)

List の foldLeft(z)(op) によるリストの畳み込みを描くと下の図のようになります。

      op
     /  \
    op   3
   /  \ 
  op   2
 /  \
z    1
list.foldLeft(0)((x, y) => x + y)
res1: Int = 6

foldLeft は次の順序で計算されます。

  1. 初期値 (z) とリストの先頭の要素を op: つまり、x に 0 がバインドされ、y に 1 が設定されて計算された 1 を返す
  2. 計算結果の 1 とリストの2番目の要素を op: つまり、x に 1 がバインドされ、y に 2 が設定され、3 を返す
  3. 計算結果の 3 とリストの3番目の要素を op: つまり、x に 3 がバインドされ、y に 3 が設定され、6 を返す

したがって、最後の計算結果である 6 が返されます。

foldLeft はリストの先頭から計算しますが、リストの末尾から計算する foldRight もあります。

AWS認定 SAP-C01 を取得した話

2022年11月15日に Solutions Architect Professional 試験が改定されて新しいバージョンになるということで、改定される前に取得しておきたいと思って、10月22日に、試験センターに行ってきました。

試験が終わって画面に「合格」と表示された時はうれしかったです。実はこの試験 3 年前に 2 度受験したものの合格基準を満たせず不合格となっていて、しばらく受験のモチベーションがなくなっていました。

最近になって同僚の 12 冠達成を見て、再度受けてみる気になりました。

獲得したバッジです。

まず、受験申込みでは、一回落ちても再チャレンジができるキャンペーンを利用しました。

AWS 認定 再受験無料キャンペーン

どんな勉強をしたか?

Output webcam directly via hdmi on tv (ATEM Mini)

ATEM Mini を購入したのを機に、Raspberry Pi を使って、USB 接続した WebCam をそのまま HDMI に出力してみました。

ここで説明する方法は、Desktop (GUI) は不要です。コンソールログイン ("B1 Console" or "B2 Console Autologin") を使用します。

方法は非常にシンプルです。単に ffplay コマンドを使うだけです。

ffplay /dev/video0

使用した製品は次のとおりです。

ソフトウェアのパターンランゲージ年表

年月日 タイトル 日本語版タイトル
1994-10-31 Design Patterns: Elements of Reusable Object-Oriented Software オブジェクト指向における再利用のためのデザインパターン
2002-11-05 Patterns of Enterprise Application Architecture エンタープライズアプリケーションアーキテクチャパターン
2003-08-22 Domain-Driven Design: Tackling Complexity in the Heart of Software エリック・エヴァンスのドメイン駆動設計
2003-10-10 Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions -
2018-01-20 Designing Distributed Systems: Patterns and Paradigms for Scalable, Reliable Services 分散システムデザインパターン ―コンテナを使ったスケーラブルなサービスの設計
2020-02-18 Fundamentals of Software Architecture: An Engineering Approach ソフトウェアアーキテクチャの基礎 ―エンジニアリングに基づく体系的アプローチ

コンテナ環境での依存性注入 (DI)

コンテナ環境でアプリケーションのコード変更なしの依存性注入パターン (dependency injection pattern) について考えてみます。

横断的関心事をアスペクト指向 (aspect oriented) に解決する方法については別の記事で概要を書いています。

アスペクト指向

少し重複もありますが、再度横断的関心事についても概説したいと思います。

f:id:section27:20220314134428p:plain

左上には、OpenID Provider と AD + AD-FS などの SAML2 フェデレーションによる、認証サービスがあります。ここには、Amazon Cognito Userpool や Keycloak、あるいは IDaaS の各種サービスを利用することができます。ユーザのログイン操作はこのようなサービスを使って実行されます。SAML 2 との連携について、より詳細には「マイクロサービスの Active Directory の活用」を参照してください。

OpenID Provider によって取得した ID トークンの妥当性を検証することで正しく認証されていると判断することができます。Front Layer の Sidecar と接続されている Authentication (認証) によってこの妥当性の検証を行うことができます。これは Envoy proxy 等を Sidecar に使用している場合はコードを書かずに既存の機能で実現可能です (「Envoy を使用して ID Token (OIDC) を検証する」参照してください)。

Authorization (認可) は、システム、アプリケーションによってさまざまなポリシーがあります。今では Open Policy Agent (OPA) というドメイン固有言語を使ってポリシーを適用することをお勧めします。Envoy Proxy を使用した例が「Envoy と Open Policy Agent を使用した認可」にあります。

マイクロサービスレイヤー

Microservices Layer での Authentication (認証) はサービス間認証を意味するため、mTLS などの認証ということになります。しかし、これも Envoy Proxy や Dapr といった Sidecar でサポートされている機能です。

認可は Front Layer と同様に OPA を利用することができます。

依存性注入 (DI) の方法

Front Layer には、JavaScript と Markups (HTML や css) をストレージに配置して変更が容易な UI を提供することが今では一般的です。AWS で S3 に配置した静的コンテンツを Envoy Proxy で提供する詳細については「S3 の静的 Web サイトを Envoy でホスティング」を参照してください。

UI が利用する API は、Front Layer に配置されます (図の 「API for Frontend」)。

ここで、この API でさまざまなシステムの機能を提供するように作成することができます。しかし、皆さんは、これまで Spring や CDI といった dependency injection pattern を使ってコードを書いてきていませんでしょうか? なぜ、そうしてきたのでしょうか?

著名な「CLean Architecture」をはじめとして、変更に強く、生産性が高く、そして保守性の良いアーキテクチャを実現できる利点から、多くのソフトウェアで DI パターンが採用されてきました。

DI パターンを利用することで得られる利点をあらためて列挙してみましょう。

  • 各レイヤーのモジュールを単一の責務で実装できる
  • レイヤー間のインターフェースの取り決めにより、それぞれのレイヤーのモジュールの差し替えが容易になる (契約による設計)。

他にも利点はありますが、上記のようにモジュールの差し替えが可能なポイントを、インジェクションポイントとも言います。逆にいうと、インジェクトションポイントが適切なところになければ、モジュールの差し替えは困難となります。これは、単に Spring や CDI といったフレームワークを利用しているかどうかとは別の設計上の問題です。

同様に、コンテナ環境でインジェクションポイントを設けるためにはどうすれば良いでしょうか。

ここまで横断的関心事については、Sidecar により実現できることを説明してきました。同様に、DI についても Sidecar によって実現することが可能です。

THE Twelve-Facor Apps」でも、依存関係を明示的に宣言し分離する、ポートバインディングを通してサービスを公開する、プロセスモデルによってスケールアウトする等々のベストプラクティスを実装するために、Sidecar とそれぞれの責務を分担するコンテナは理想的だと考えています。

Front Layer にある「API for Frontend」コンポーネントや Microservices Layer にある「Service」コンポーネントの I/F がインジェクションポイントとなり、たとえば、図にあるように Kafka などのメッセージング基盤の「Publisher」、「Subscriber」それと「HTTP Gateway」のコンポーネントを Sidecar に設定することで、実装された「API for Frontend」、「Service」コンポーネントへのリクエストを HTTP Gateway と Subscribe に切り替えたり、あるいはレスポンス先を HTTP Gateway と Publisher に切り替えることで同期型から非同期型への変更が容易になります。HTTP のままであったとしても、Blue/Green やカナリアのためにバージョンの異なるコンポーネントに振り分けることもできます。

図にはありませんが、たとえば、データベースアクセスに特化した I/F を持つコンテナを設計したとすれば、PostgreSQLMySQL、DynamoDB などの NoSQL 等にアクセスするコンテナを用意して、それぞれのユーザ環境への対応が容易になります。

まとめ

Spring や CDI など、これまでコード内のレイヤー分割で必要とされてきた依存性注入 (DI) の概念を Sidecar パターンを利用してコンテナ間で可能とする方法について説明しました。

ただし、DI を活用するためには誰もが理解できるインジェクションポイントが重要であり、Spring や CDI で培ってきたレイヤーアーキテクチャの重要性は変わっていないことに注意してください。

OpenFaaS

OpenFaaS は「Raspberry Pi 3 で Amazon Linux 2」 でインストールした containerd だけでも実行するだけなら可能です。後述の custom functions の作成では docker-ce をインストールする必要があります。

インストール

faasd に記述された手順を参照して faasd をインストールします。

git clone https://github.com/openfaas/faasd --depth=1
cd faasd

./hack/install.sh

下のコマンドで表示されるように、OpenFaaS のコンテナイメージはネームスペース openfaas で実行されています。

nerdctl ps --all --namespace openfaas
CONTAINER ID    IMAGE                                      COMMAND                   CREATED         STATUS    PORTS    NAMES
basic-auth-p    ghcr.io/openfaas/basic-auth:0.21.0         "./handler"               19 hours ago    Up                     
gateway         ghcr.io/openfaas/gateway:0.21.3            "./gateway"               19 hours ago    Up                     
nats            docker.io/library/nats-streaming:0.22.0    "/nats-streaming-ser…"    19 hours ago    Up                     
prometheus      docker.io/prom/prometheus:v2.14.0          "/bin/prometheus --c…"    19 hours ago    Up                     
queue-worker    ghcr.io/openfaas/queue-worker:0.12.2       "./app"                   19 hours ago    Up  

ファンクションのデプロイとテスト

OpenFaaS 実行環境にログインします。ブラウザで http://localhost:8080 にアクセスする場合の ID は admin です。これは /var/lib/faasd/secrets/basic-auth-user に書かれています。また、パスワードは、下のコマンドラインと同様に、/var/lib/faasd/secrets/basic-auth-password に書かれています。

sudo cat /var/lib/faasd/secrets/basic-auth-password | faas-cli login --password-stdin

store にはあらかじめすぐにデプロイできるファンクションが用意されています。これを確認する場合は、次のコマンドを実行します。

faas-cli store list

ここでは、環境変数を返す env をデプロイして実行してみます。

faas-cli store deploy env
Deployed. 200 OK.
URL: http://127.0.0.1:8080/function/env

以下のように実行します。

echo -n "" | faas-cli invoke env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
fprocess=env
HOME=/home/app
Http_User_Agent=Go-http-client/1.1
Http_Content_Type=text/plain
Http_X_Call_Id=63ed420b-a2aa-408e-b5d0-a8288a99e086
Http_X_Start_Time=1646405431615286054
Http_Content_Length=0
Http_Accept_Encoding=gzip
Http_X_Forwarded_For=10.62.0.1:41774
Http_X_Forwarded_Host=127.0.0.1:8080
Http_Method=POST
Http_ContentLength=0
Http_Path=/
Http_Host=10.62.0.8:8080

カスタムファンクションのビルドと実行

ここからは、containerd だけでなく docker-ce、docker-cli が必要になります。

java 11 のテンプレートを使って hello プロジェクトを生成します。

faas-cli new hello --lang java11
Folder: hello created.
  ___                   _____           ____
 / _ \ _ __   ___ _ __ |  ___|_ _  __ _/ ___|
| | | | '_ \ / _ \ '_ \| |_ / _` |/ _` \___ \
| |_| | |_) |  __/ | | |  _| (_| | (_| |___) |
 \___/| .__/ \___|_| |_|_|  \__,_|\__,_|____/
      |_|


Function created in folder: hello
Stack file written: hello.yml

Notes:
You have created a function using the java11 template which uses an LTS
version of the OpenJDK.

hello.yml の image を使用する Docker Registry にあわせて変更します。たとえば、私は DockerHub のアカウントを利用するため、takesection/hello にしました。

hello ファンクションのコンテナイメージをビルドします。

faas-cli build -f ./hello.yml

Docker Registry にログインします。

docker login

Docker Registry に push します。

faas-cli push -f ./hello.yml

OpenFaaS 実行環境にデプロイします。

faas-cli deploy -f ./hello.yml

ここでは、ブラウザを使って試してみましょう。

http://localhost:8080/ にアクセスします (図では別の PC からアクセスしているため、OpenFaaS を実行しているホスト名を指定しています)。

f:id:section27:20220311101500p:plain

実行したいファクションを選択して INVOKE ボタンを押すと実行できます。

f:id:section27:20220311101532p:plain

参考

開発者と運用者の役割

ここでは、Java によるエンタープライズ Web アプリケーションを例に DevOps を導入した場合の開発者と運用者の役割について考えてみました。

初期の頃のオンプレミスで実行される Web アプリケーションを概略図で示すと下図のようになっていました。

f:id:section27:20220309121702p:plain

一般的に、開発者の職務と運用者の職務は分離され、開発者は最終成果物のアプリケーションの設計、開発、テストと ear や war などにパッケージングするところまでを担当し、運用者は、ファイアウォール、ロードバランサなどのネットワークインフラストラクチャーの構築、アプリケーションサーバなどの実行環境の構築、データベースインフラストラクチャーの構築や、さらには監視や開発者から受け取ったパッケージをアプリケーションサーバにインストールすることなど、さまざまな事を担当してきました。

サービス指向アーキテクチャ (SOA) の時代を経て、さらにクラウドネイティブな時代となった今では、これまでのように、必要な非機能要件 (認証、認可、ログ、トレーシング等) やデータベースへのリソースアクセスのコードを含むパッケージングされたアプリケーション (fat-jar やもう見かけることも少なくなった ear や war) とするアーキテクチャ以外に、これまでインフラストラクチャーとみなされていた、ファイアウォールやロードバランサ、アプリケーションサーバ (下の図の proxy) で機能するものを採用できるようになり、またデータベースもこれまでのようなリレーショナルデータベースだけでなく、さまざまなデータストレージも登場しています。

f:id:section27:20220309121725p:plain

さらには、仮想コンピューティング (VM やコンテナ、クラウドサービスなど) の進化により、インフラストラクチャーと考えられてきたネットワークやサーバでさえ、IaC のようにコードにより調達、変更、廃棄が可能となりました。

インフラストラクチャーがこのように変化したとき、開発者の役割は、アーキテクチャ設計においてインフラストラクチャーを無視できず、この領域にまたがるとともに、運用者の役割もまたアプリケーションの実行のために必要な情報がより増えることを意味します。

DevOps ムーブメントにより、初期の頃に比べればはるかに複雑な構成になったとはいえ、IaC も含む継続的インテグレーション継続的デリバリーにより、インフラストラクチャーの構築を開発者で行うことも可能となっています。

このような状況下で、開発者はアプリケーションにフォーカスし、運用者はインフラストラクチャーにフォーカスするという役割のままでよいのか、またアプリケーションとインフラストラクチャーの境界線がどこにあり、役割をそのままとするのであれば、相互にまたがった場合の調整コストをどうするのかという課題があると考えています。

コンプライアンスの観点からは、参考資料「トラストをともに駆ける―― DevOpsにおけるコンプライアンス対応の要所」がとても優れたドキュメントでぜひ読んでいただきたいと思います。

参考