用Spray构建RESTful接口

原创
2015/10/08 17:34
阅读数 3.7K

可能很多人都在使用play2,因为play2就像Grails一样,直接download安装就可以用了,上手快,而且有Java版本。另外lift文档比较少,学习成本高,暂时不考虑使用lift(太难学)。Spray是个半成品,只包含RESTful,并且是基于akka,路由DSL设计,以及支持Servlet3.0实现。

基础文件配置

不多说,首先是构建SBT依赖:

import AssemblyKeys._

name := "rest"

version := "1.0"

scalaVersion := "2.10.5"

libraryDependencies ++= Seq(
    "io.spray" % "spray-can" % "1.1-M8",
    "io.spray" % "spray-http" % "1.1-M8",
    "io.spray" % "spray-routing" % "1.1-M8",
    "net.liftweb" %% "lift-json" % "2.5.1",
    "com.typesafe.slick" %% "slick" % "1.0.1",
    "mysql" % "mysql-connector-java" % "5.1.25",
    "com.typesafe.akka" %% "akka-actor" % "2.1.4",
    "com.typesafe.akka" %% "akka-slf4j" % "2.1.4",
    "ch.qos.logback" % "logback-classic" % "1.0.13"
)

resolvers ++= Seq(
    "Spray repository" at "http://repo.spray.io",
    "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/"
)

然后添加上相应的插件:

resolvers ++= Seq(
    "Sonatype snapshots" at "http://oss.sonatype.org/content/repositories/snapshots/",
    "Sonatype releases"  at "https://oss.sonatype.org/content/repositories/releases/"
)
addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2")
addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.2")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.9.0")

配置文件application.conf添加上日志和数据源:

akka {
  loglevel = DEBUG
  event-handlers = ["akka.event.slf4j.Slf4jEventHandler"]
}
service {
    host = "localhost"
    port = 8080
}
db {
    host = "localhost"
    port = 3306
    name = "rest"
    user = "root"
    password = null
}

lockback日志不多说了,自行在resources中添加即可。

引导配置

设置配置变量:

import com.typesafe.config.ConfigFactory
import util.Try

/**
 * Holds service configuration settings.
 */
trait Configuration {

  /**
   * Application config object.
   */
  val config = ConfigFactory.load()

  /** Host name/address to start service on. */
  lazy val serviceHost = Try(config.getString("service.host")).getOrElse("localhost")

  /** Port to start service on. */
  lazy val servicePort = Try(config.getInt("service.port")).getOrElse(8080)

  /** Database host name/address. */
  lazy val dbHost = Try(config.getString("db.host")).getOrElse("localhost")

  /** Database host port number. */
  lazy val dbPort = Try(config.getInt("db.port")).getOrElse(3306)

  /** Service database name. */
  lazy val dbName = Try(config.getString("db.name")).getOrElse("rest")

  /** User name used to access database. */
  lazy val dbUser = Try(config.getString("db.user")).toOption.orNull

  /** Password for specified user and database. */
  lazy val dbPassword = Try(config.getString("db.password")).toOption.orNull
}

客户端启动引导:

import akka.actor.{Props, ActorSystem}
import akka.io.IO
import com.madoka.example.config.Configuration
import com.madoka.example.rest.RestServiceActor
import spray.can.Http

object Boot extends App with Configuration {

  // create an actor system for application
  implicit val system = ActorSystem("rest-service-example")

  // create and start rest service actor
  val restService = system.actorOf(Props[RestServiceActor], "rest-endpoint")

  // start HTTP server with rest service actor as a handler
  IO(Http) ! Http.Bind(restService, serviceHost, servicePort)
}

领域模式构建

创建DAO层和实体:

/**
 * Provides DAL for Customer entities for MySQL database.
 */
class CustomerDAO extends Configuration {

  // init Database instance
  private val db = Database.forURL(url = "jdbc:mysql://%s:%d/%s".format(dbHost, dbPort, dbName),
    user = dbUser, password = dbPassword, driver = "com.mysql.jdbc.Driver")

  // create tables if not exist
  db.withSession {
    if (MTable.getTables("customers").list().isEmpty) {
      Customers.ddl.create
    }
  }

  /**
   * Saves customer entity into database.
   *
   * @param customer customer entity to
   * @return saved customer entity
   */
  def create(customer: Customer): Either[Failure, Customer] = {
    try {
      val id = db.withSession {
        Customers returning Customers.id insert customer
      }
      Right(customer.copy(id = Some(id)))
    } catch {
      case e: SQLException =>
        Left(databaseError(e))
    }
  }

  /**
   * Updates customer entity with specified one.
   *
   * @param id       id of the customer to update.
   * @param customer updated customer entity
   * @return updated customer entity
   */
  def update(id: Long, customer: Customer): Either[Failure, Customer] = {
    try
      db.withSession {
        Customers.where(_.id === id) update customer.copy(id = Some(id)) match {
          case 0 => Left(notFoundError(id))
          case _ => Right(customer.copy(id = Some(id)))
        }
      }
    catch {
      case e: SQLException =>
        Left(databaseError(e))
    }
  }

  /**
   * Deletes customer from database.
   *
   * @param id id of the customer to delete
   * @return deleted customer entity
   */
  def delete(id: Long): Either[Failure, Customer] = {
    try {
      db.withTransaction {
        val query = Customers.where(_.id === id)
        val customers = query.run.asInstanceOf[Vector[Customer]]
        customers.size match {
          case 0 =>
            Left(notFoundError(id))
          case _ =>
            query.delete
            Right(customers.head)
        }
      }
    } catch {
      case e: SQLException =>
        Left(databaseError(e))
    }
  }

  /**
   * Retrieves specific customer from database.
   *
   * @param id id of the customer to retrieve
   * @return customer entity with specified id
   */
  def get(id: Long): Either[Failure, Customer] = {
    try {
      db.withSession {
        Customers.findById(id).firstOption match {
          case Some(customer: Customer) =>
            Right(customer)
          case _ =>
            Left(notFoundError(id))
        }
      }
    } catch {
      case e: SQLException =>
        Left(databaseError(e))
    }
  }

  /**
   * Retrieves list of customers with specified parameters from database.
   *
   * @param params search parameters
   * @return list of customers that match given parameters
   */
  def search(params: CustomerSearchParameters): Either[Failure, List[Customer]] = {
    implicit val typeMapper = Customers.dateTypeMapper

    try {
      db.withSession {
        val query = for {
          customer <- Customers if {
          Seq(
            params.firstName.map(customer.firstName is _),
            params.lastName.map(customer.lastName is _),
            params.birthday.map(customer.birthday is _)
          ).flatten match {
            case Nil => ConstColumn.TRUE
            case seq => seq.reduce(_ && _)
          }
        }
        } yield customer

        Right(query.run.toList)
      }
    } catch {
      case e: SQLException =>
        Left(databaseError(e))
    }
  }

  /**
   * Produce database error description.
   *
   * @param e SQL Exception
   * @return database error description
   */
  protected def databaseError(e: SQLException) =
    Failure("%d: %s".format(e.getErrorCode, e.getMessage), FailureType.DatabaseFailure)

  /**
   * Produce customer not found error description.
   *
   * @param customerId id of the customer
   * @return not found error description
   */
  protected def notFoundError(customerId: Long) =
    Failure("Customer with id=%d does not exist".format(customerId), FailureType.NotFound)
}

创建实体模型:

import scala.slick.driver.MySQLDriver.simple._

/**
 * Customer entity.
 *
 * @param id        unique id
 * @param firstName first name
 * @param lastName  last name
 * @param birthday  date of birth
 */
case class Customer(id: Option[Long], firstName: String, lastName: String, birthday: Option[java.util.Date])

/**
 * Mapped customers table object.
 */
object Customers extends Table[Customer]("customers") {

  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)

  def firstName = column[String]("first_name")

  def lastName = column[String]("last_name")

  def birthday = column[java.util.Date]("birthday", O.Nullable)

  def * = id.? ~ firstName ~ lastName ~ birthday.? <>(Customer, Customer.unapply _)

  implicit val dateTypeMapper = MappedTypeMapper.base[java.util.Date, java.sql.Date](
  {
    ud => new java.sql.Date(ud.getTime)
  }, {
    sd => new java.util.Date(sd.getTime)
  })

  val findById = for {
    id <- Parameters[Long]
    c <- this if c.id is id
  } yield c
}

创建参数模型:

/**
 * Customers search parameters.
 *
 * @param firstName first name
 * @param lastName  last name
 * @param birthday  date of birth
 */
case class CustomerSearchParameters(firstName: Option[String] = None,
                                    lastName: Option[String] = None,
                                    birthday: Option[Date] = None)

Actor模型

HTTP REST函数服务:

/**
 * REST Service actor.
 */
class RestServiceActor extends Actor with RestService {

  implicit def actorRefFactory = context

  def receive = runRoute(rest)
}

/**
 * REST Service
 */
trait RestService extends HttpService with SLF4JLogging {

  val customerService = new CustomerDAO

  implicit val executionContext = actorRefFactory.dispatcher

  implicit val liftJsonFormats = new Formats {
    val dateFormat = new DateFormat {
      val sdf = new SimpleDateFormat("yyyy-MM-dd")

      def parse(s: String): Option[Date] = try {
        Some(sdf.parse(s))
      } catch {
        case e: Exception => None
      }

      def format(d: Date): String = sdf.format(d)
    }
  }

  implicit val string2Date = new FromStringDeserializer[Date] {
    def apply(value: String) = {
      val sdf = new SimpleDateFormat("yyyy-MM-dd")
      try Right(sdf.parse(value))
      catch {
        case e: ParseException =>
          Left(MalformedContent("'%s' is not a valid Date value" format value, e))
      }
    }
  }

  implicit val customRejectionHandler = RejectionHandler {
    case rejections => mapHttpResponse {
      response =>
        response.withEntity(HttpEntity(ContentType(MediaTypes.`application/json`),
          write(Map("error" -> response.entity.asString))))
    } {
      RejectionHandler.Default(rejections)
    }
  }

  val rest = respondWithMediaType(MediaTypes.`application/json`) {
    path("customer") {
      post {
        entity(Unmarshaller(MediaTypes.`application/json`) {
          case httpEntity: HttpEntity =>
            read[Customer](httpEntity.asString(HttpCharsets.`UTF-8`))
        }) {
          customer: Customer =>
            ctx: RequestContext =>
              handleRequest(ctx, StatusCodes.Created) {
                log.debug("Creating customer: %s".format(customer))
                customerService.create(customer)
              }
        }
      } ~
        get {
          parameters('firstName.as[String] ?, 'lastName.as[String] ?, 'birthday.as[Date] ?).as(CustomerSearchParameters) {
            searchParameters: CustomerSearchParameters => {
              ctx: RequestContext =>
                handleRequest(ctx) {
                  log.debug("Searching for customers with parameters: %s".format(searchParameters))
                  customerService.search(searchParameters)
                }
            }
          }
        }
    } ~
      path("customer" / LongNumber) {
        customerId =>
          put {
            entity(Unmarshaller(MediaTypes.`application/json`) {
              case httpEntity: HttpEntity =>
                read[Customer](httpEntity.asString(HttpCharsets.`UTF-8`))
            }) {
              customer: Customer =>
                ctx: RequestContext =>
                  handleRequest(ctx) {
                    log.debug("Updating customer with id %d: %s".format(customerId, customer))
                    customerService.update(customerId, customer)
                  }
            }
          } ~
            delete {
              ctx: RequestContext =>
                handleRequest(ctx) {
                  log.debug("Deleting customer with id %d".format(customerId))
                  customerService.delete(customerId)
                }
            } ~
            get {
              ctx: RequestContext =>
                handleRequest(ctx) {
                  log.debug("Retrieving customer with id %d".format(customerId))
                  customerService.get(customerId)
                }
            }
      }
  }

  /**
   * Handles an incoming request and create valid response for it.
   *
   * @param ctx         request context
   * @param successCode HTTP Status code for success
   * @param action      action to perform
   */
  protected def handleRequest(ctx: RequestContext, successCode: StatusCode = StatusCodes.OK)(action: => Either[Failure, _]) {
    action match {
      case Right(result: Object) =>
        ctx.complete(successCode, write(result))
      case Left(error: Failure) =>
        ctx.complete(error.getStatusCode, net.liftweb.json.Serialization.write(Map("error" -> error.message)))
      case _ =>
        ctx.complete(StatusCodes.InternalServerError)
    }
  }
}

状态代码代数数据类型(ADT):

import spray.http.{StatusCodes, StatusCode}

/**
 * Service failure description.
 *
 * @param message   error message
 * @param errorType error type
 */
case class Failure(message: String, errorType: FailureType.Value) {

  /**
   * Return corresponding HTTP status code for failure specified type.
   *
   * @return HTTP status code value
   */
  def getStatusCode: StatusCode = {
    FailureType.withName(this.errorType.toString) match {
      case FailureType.BadRequest => StatusCodes.BadRequest
      case FailureType.NotFound => StatusCodes.NotFound
      case FailureType.Duplicate => StatusCodes.Forbidden
      case FailureType.DatabaseFailure => StatusCodes.InternalServerError
      case _ => StatusCodes.InternalServerError
    }
  }
}

/**
 * Allowed failure types.
 */
object FailureType extends Enumeration {
  type Failure = Value

  val BadRequest = Value("bad_request")
  val NotFound = Value("not_found")
  val Duplicate = Value("entity_exists")
  val DatabaseFailure = Value("database_error")
  val InternalError = Value("internal_error")
}

测试REST功能

启动Boot服务:

在目录结构中运行

sbt run

或者先构建

sbt assembly

再运行

java -jar <path-to-assembly.jar>

又或者直接通过IDE工具右键运行,成功后将在控制台出现

2015-10-08 17:20:41 INFO  [rest-service-example-akka.actor.default-dispatcher-4] a.e.s.Slf4jEventHandler - Slf4jEventHandler started
2015-10-08 17:20:42 DEBUG [rest-service-example-akka.actor.default-dispatcher-3] akka://rest-service-example/user/IO-HTTP/listener-0 - Binding to localhost/127.0.0.1:8080
2015-10-08 17:20:42 INFO  [rest-service-example-akka.actor.default-dispatcher-2] akka://rest-service-example/user/IO-HTTP/listener-0 - Bound to localhost/127.0.0.1:8080

如果使用集成开发工具,可以直接通过RESTClient测试

又或者通过CURL工具进行测试

POST方法

curl -v -X POST http://localhost:8080/customer -H "Content-Type: application/json" -d '{"firstName":
"First", "lastName":"Last", "birthday":"1990-01-01"}'

GET方法

curl -v -X GET http://localhost:8080/customer/1

错误请求

curl -v -X GET http://localhost:8080/customer/1000

由于PUT方法是等幂的,因此可以用于更新操作来处理表单的重复发送请求,但是不常用。


展开阅读全文
打赏
1
10 收藏
分享
打赏
0 评论
10 收藏
1
分享
在线直播报名
返回顶部
顶部