在雲端計算的推動下,軟體系統發展趨於平臺化。雲平臺系統一般都是分散式的叢集系統,採用大資料技術。在這方面akka提供了比較完整的開發技術支援。我在上一個系列有關CQRS的部落格中按照實際應用的要求對akka的一些開發技術進行了介紹。CQRS模式著重操作流程控制,主要涉及交易資料的管理。那麼,作為交易資料產生過程中發揮驗證作用的一系列基礎資料如使用者資訊、商品資訊、支付型別資訊等又應該怎樣維護呢?首先基礎資料也應該是在平臺水平上的,但資料的採集、維護是在系統前端的,比如一些web介面。所以平臺基礎資料維護系統是一套前後臺結合的系統。對於一個開放的平臺系統來說,應該能夠適應各式各樣的前端系統。一般來講,平臺通過定義一套api與前端系統整合是通用的方法。這套api必須遵循行業標準,技術要普及通用,這樣才能支援各種異類前端系統功能開發。在這些要求背景下,相對gRPC, GraphQL來說,REST風格的http整合模式能得到更多開發人員的接受。
在有關CQRS系列部落格裡,我以akka-http作為系統整合工具的一種,零星地針對實際需要對http通訊進行了介紹。在restapi這個系列裡我想系統化的用akka-http構建一套完整的,REST風格資料維護和資料交換api,除CRUD之外還包括網路安全,檔案交換等功能。我的計劃是用akka-http搭建一個平臺資料維護api的REST-CRUD框架,包含所有標配功能如使用者驗證、異常處理等。CRUD部分要儘量做成通用的generic,框架型的,能用一套標準的方法對任何資料表進行操作。
akka-http是一套http程式開發工具。它的Routing-DSL及資料序列化marshalling等都功能強大。特別是HttpResponse處理,一句complete解決了一大堆問題,magnet-pattern結合marshalling讓它的使用更加方便。
在這篇討論裡先搭一個restapi的基本框架,包括客戶端身份驗證和使用許可權。主要是示範如何達到通用框架的目的。這個在akka-http程式設計裡主要體現在Routing-DSL的結構上,要求Route能夠簡潔易懂,如下:
val route =
path("auth") {
authenticateBasic(realm = "auth", authenticator.getUserInfo) { userinfo =>
post { complete(authenticator.issueJwt(userinfo))}
}
} ~
pathPrefix("api") {
authenticateOAuth2(realm = "api", authenticator.authenticateToken) { validToken =>
(path("hello") & get) {
complete(s"Hello! userinfo = ${authenticator.getUserInfo(validToken)}")
} ~
(path("how are you") & get) {
complete(s"Hello! userinfo = ${authenticator.getUserInfo(validToken)}")
}
// ~ ...
}
}
我覺著這應該是框架型正確的方向:把所有功能都放在api下,統統經過許可權驗證。可以直接在後面不斷加功能Route。
身份驗證和使用許可權也應該是一套標準的東西,但身份驗證方法可能有所不同,特別是使用者身份驗證可能是通過獨立的身份驗證伺服器實現的,對不同的驗證機制應該有針對性的定製函式。構建身份管理的物件應該很方便或者很通用,如下:
val authenticator = new AuthBase()
.withAlgorithm(JwtAlgorithm.HS256)
.withSecretKey("OpenSesame")
.withUserFunc(getValidUser)
AuthBase原始碼如下:
package com.datatech.restapi
import akka.http.scaladsl.server.directives.Credentials
import pdi.jwt._
import org.json4s.native.Json
import org.json4s._
import org.json4s.jackson.JsonMethods._
import pdi.jwt.algorithms._
import scala.util._
object AuthBase {
type UserInfo = Map[String, Any]
case class AuthBase(
algorithm: JwtAlgorithm = JwtAlgorithm.HMD5,
secret: String = "OpenSesame",
getUserInfo: Credentials => Option[UserInfo] = null) {
ctx =>
def withAlgorithm(algo: JwtAlgorithm): AuthBase = ctx.copy(algorithm=algo)
def withSecretKey(key: String): AuthBase = ctx.copy(secret = key)
def withUserFunc(f: Credentials => Option[UserInfo]): AuthBase = ctx.copy(getUserInfo = f)
def authenticateToken(credentials: Credentials): Option[String] =
credentials match {
case Credentials.Provided(token) =>
algorithm match {
case algo: JwtAsymmetricAlgorithm =>
Jwt.isValid(token, secret, Seq((algorithm.asInstanceOf[JwtAsymmetricAlgorithm]))) match {
case true => Some(token)
case _ => None
}
case _ =>
Jwt.isValid(token, secret, Seq((algorithm.asInstanceOf[JwtHmacAlgorithm]))) match {
case true => Some(token)
case _ => None
}
}
case _ => None
}
def getUserInfo(token: String): Option[UserInfo] = {
algorithm match {
case algo: JwtAsymmetricAlgorithm =>
Jwt.decodeRawAll(token, secret, Seq(algorithm.asInstanceOf[JwtAsymmetricAlgorithm])) match {
case Success(parts) => Some(((parse(parts._2).asInstanceOf[JObject]) \ "userinfo").values.asInstanceOf[UserInfo])
case Failure(err) => None
}
case _ =>
Jwt.decodeRawAll(token, secret, Seq(algorithm.asInstanceOf[JwtHmacAlgorithm])) match {
case Success(parts) => Some(((parse(parts._2).asInstanceOf[JObject]) \ "userinfo").values.asInstanceOf[UserInfo])
case Failure(err) => None
}
}
}
def issueJwt(userinfo: UserInfo): String = {
val claims = JwtClaim() + Json(DefaultFormats).write(("userinfo", userinfo))
Jwt.encode(claims, secret, algorithm)
}
}
}
我已經把多個通用的函式封裝在裡面了。再模擬一個使用者身份管理物件:
package com.datatech.restapi
import akka.http.scaladsl.server.directives.Credentials
import AuthBase._
object MockUserAuthService {
case class User(username: String, password: String, userInfo: UserInfo)
val validUsers = Seq(User("johnny", "p4ssw0rd",Map("shopid" -> "1101", "userid" -> "101"))
,User("tiger", "secret", Map("shopid" -> "1101" , "userid" -> "102")))
def getValidUser(credentials: Credentials): Option[UserInfo] =
credentials match {
case p @ Credentials.Provided(_) =>
validUsers.find(user => user.username == p.identifier && p.verify(user.password)) match {
case Some(user) => Some(user.userInfo)
case _ => None
}
case _ => None
}
}
好了,服務端示範程式碼中可以直接構建或者呼叫這些標準的型別了:
package com.datatech.restapi
import akka.actor._
import akka.stream._
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
import pdi.jwt._
import AuthBase._
import MockUserAuthService._
object RestApiServer extends App {
implicit val httpSys = ActorSystem("httpSystem")
implicit val httpMat = ActorMaterializer()
implicit val httpEC = httpSys.dispatcher
val authenticator = new AuthBase()
.withAlgorithm(JwtAlgorithm.HS256)
.withSecretKey("OpenSesame")
.withUserFunc(getValidUser)
val route =
path("auth") {
authenticateBasic(realm = "auth", authenticator.getUserInfo) { userinfo =>
post { complete(authenticator.issueJwt(userinfo))}
}
} ~
pathPrefix("api") {
authenticateOAuth2(realm = "api", authenticator.authenticateToken) { validToken =>
(path("hello") & get) {
complete(s"Hello! userinfo = ${authenticator.getUserInfo(validToken)}")
} ~
(path("how are you") & get) {
complete(s"Hello! userinfo = ${authenticator.getUserInfo(validToken)}")
}
// ~ ...
}
}
val (port, host) = (50081,"192.168.11.189")
val bindingFuture = Http().bindAndHandle(route,host,port)
println(s"Server running at $host $port. Press any key to exit ...")
scala.io.StdIn.readLine()
bindingFuture.flatMap(_.unbind())
.onComplete(_ => httpSys.terminate())
}
就是說後面的http功能可以直接插進這個框架,精力可以完全聚焦於具體每項功能的開發上了。
然後用下面的客戶端測試程式碼:
import akka.actor._
import akka.stream._
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.headers._
import scala.concurrent._
import akka.http.scaladsl.model._
import pdi.jwt._
import org.json4s._
import org.json4s.jackson.JsonMethods._
import scala.util._
import scala.concurrent.duration._
object RestApiClient {
type UserInfo = Map[String,Any]
def main(args: Array[String]): Unit = {
implicit val system = ActorSystem()
implicit val materializer = ActorMaterializer()
// needed for the future flatMap/onComplete in the end
implicit val executionContext = system.dispatcher
val helloRequest = HttpRequest(uri = "http://192.168.11.189:50081/")
val authorization = headers.Authorization(BasicHttpCredentials("johnny", "p4ssw0rd"))
val authRequest = HttpRequest(
HttpMethods.POST,
uri = "http://192.168.11.189:50081/auth",
headers = List(authorization)
)
val futToken: Future[HttpResponse] = Http().singleRequest(authRequest)
val respToken = for {
resp <- futToken
jstr <- resp.entity.dataBytes.runFold("") {(s,b) => s + b.utf8String}
} yield jstr
val jstr = Await.result[String](respToken,2 seconds)
println(jstr)
scala.io.StdIn.readLine()
val parts = Jwt.decodeRawAll(jstr, "OpenSesame", Seq(JwtAlgorithm.HS256)) match {
case Failure(exception) => println(s"Error: ${exception.getMessage}")
case Success(value) =>
println(((parse(value._2).asInstanceOf[JObject]) \ "userinfo").values.asInstanceOf[UserInfo])
}
scala.io.StdIn.readLine()
val authentication = headers.Authorization(OAuth2BearerToken(jstr))
val apiRequest = HttpRequest(
HttpMethods.GET,
uri = "http://192.168.11.189:50081/api/hello",
).addHeader(authentication)
val futAuth: Future[HttpResponse] = Http().singleRequest(apiRequest)
println(Await.result(futAuth,2 seconds))
scala.io.StdIn.readLine()
system.terminate()
}
}
build.sbt
name := "restapi"
version := "0.1"
scalaVersion := "2.12.8"
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-http" % "10.1.8",
"com.typesafe.akka" %% "akka-stream" % "2.5.23",
"com.pauldijou" %% "jwt-core" % "3.0.1",
"de.heikoseeberger" %% "akka-http-json4s" % "1.22.0",
"org.json4s" %% "json4s-native" % "3.6.1",
"com.typesafe.akka" %% "akka-http-spray-json" % "10.1.8",
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.0",
"org.slf4j" % "slf4j-simple" % "1.7.25",
"org.json4s" %% "json4s-jackson" % "3.6.7",
"org.json4s" %% "json4s-ext" % "3.6.7"
)