Swift语言开发App服务端

概述

我自从Apple发布Swift之后就开始使用Swift了。在那之前更多的使用的是Objective-C,在Swift发布后很快就喜欢上了这门语言。虽然这几年Swift从1.0到现在的4.0不断地在变化,每一次版本升级都经历了万般痛苦,但始终没有影响我对Swift的热爱。16年Swift开源,在一些小型应用上逐步开始使用Swift。

对比尝试过Perfect、Vapor、Kitura,最后确定持续使用Perfect,在github上Perfect至今已经累积了12.6k个星,不难看出大家有多么兴奋和愿望用Swift开发服务器端了。Perfect作为一个服务器框架集成了强大的功能特性。

我至今在1个网络小说应用、2个社交应用、1个视频会议的应用上使用了Swift作为服务器端开发语言,其中有2个还有需要支持Web,作为app最常用到的交互方式就是http和websocket,数据存储无非是mongodb、redis、mysql等。这些足以支持我们构建功能完整的app服务端了。

不想将各个框架一一对比,更不想贴那张跑分的图片来彰显它的强大,只想简单说说我的Swift服务端干了什么,希望更多的人使用并推动Swift的发展。

以下的示例代码均已升级到Swift4。

运行环境

1、树莓派3:ubuntu16.04 armv7,swift3.0
2、dell Optiplex 775: ubuntu16.04 x86_64,swift4.0

应用

网络

使用最常用的http和websocket

HTTP

网络小说应用(快搜神器)中使用的是纯HTTP交互方式

创建http服务

import PerfectLib
import PerfectHTTP
import PerfectHTTPServer

// Create HTTP server.
let server = HTTPServer()
server.serverAddress = "0.0.0.0"
server.serverPort = 10000
server.documentRoot = "/wwwroot"

do {
    // Launch the HTTP server.
    try server.start()
} catch PerfectError.networkError(let err, let msg) {
    print("Network error thrown: \(err) \(msg)")
}

最常使用的get或post请求

//MARK: 自动推荐搜索关键字
routes.add(method: .get, uri: "/suggestSearch", handler: BookHandler.suggestSearchKeys)
import PerfectHTTP
class BookHandler:NetworkHandler {
    //推荐搜索关键字
    class func suggestSearchKeys(request: HTTPRequest, _ response: HTTPResponse) {
        print("\(#function) uri:\(request.uri)")

        //获取参数
        guard let key = valueForKey(request: request, key: "key") else {
            responseReq(response: response, returnCode: .parmarError, errMsg: "params data error", data: nil)
            return
        }
        
        var data:Dictionary = [:]
        
        responseReq(response: response, returnCode: .success, errMsg: "ok", data: data)
    }
    
    private class func valueForKey(request:HTTPRequest, key:String) -> String? {
        if request.method == .get {
            let params = request.queryParams
            for (k,v) in params {
                if k == key {
                    return v
                }
            }
        } else if request.method == .post {
            let params = request.params()
            for (k,v) in params {
                if k == key {
                    return v
                }
            }
        }
        return nil
    }
    
    private class func responseReq(response: HTTPResponse, returnCode:ReturnCode, errMsg:String, data:Dictionary?) {
        response.setHeader(.contentType, value: "application/json")
        response.status = .ok //200
        
        var bodyDict:Dictionary = data == nil ? Dictionary():data!
        bodyDict["code"] = returnCode.rawValue
        bodyDict["msg"] = errMsg
        var bodyJson = ""
        do {
            bodyJson = try bodyDict.jsonEncodedString()
        } catch _ {
        }
        response.appendBody(string: bodyJson)
        response.completed()
    }
}

文件的上传和下载

创建本地文件存储路径

// 创建文件路径
let serverDocumentDir = Dir(server.documentRoot)
let uploadDir = Dir(server.documentRoot + "/uploads")
let downloadDir = Dir(server.documentRoot + "/downloads")
do {
    try serverDocumentDir.create()
    try apnsDir.create()
    for d in [uploadDir,downloadDir] {
        let subDir = Dir(d.path)
        try subDir.create()
    }
} catch {
    logger.log(.error, msg: "create dir failed:\(error)")
}

文件上传测试页面

routes.add(method: .get, uri: "/testUpload", handler: {(request: HTTPRequest, response: HTTPResponse) in
    response.status = .ok //200

    var body = ""
    body += "\n"
    body += "
" body += "
" body += "" body += "
" body += "\n" response.appendBody(string: body) response.completed() })

文件上传

routes.add(method: .post, uri: "/upload", handler: {(request: HTTPRequest, response: HTTPResponse) in
    print("\(#function) uri:\(request.uri)")
    let webRoot = request.documentRoot
    mustacheRequest(request: request, response: response, handler: UploadHandler(), templatePath: webRoot + "/response.mustache")
})

文件下载

routes.add(method: .get, uri: "/download/**", handler: DownloadHandler.download)

支持Web端需要注意跨域限制

使用Perfect-Session轻松解决,这个遇到的时候卡了好久......
web端直接使用XMLHttpRequest就行了,不需要其它配置。

import PerfectSession

//START: CORS跨域设置
SessionConfig.name = "SessionMemoryDrivers"
SessionConfig.idle = 3600

SessionConfig.cookieDomain = ""
SessionConfig.IPAddressLock = true
SessionConfig.userAgentLock = true
SessionConfig.CSRF.checkState = true

SessionConfig.CORS.enabled = true
SessionConfig.CORS.acceptableHostnames.append("*")
SessionConfig.CORS.maxAge = 3600


let sessionDriver = SessionMemoryDriver()

server.setRequestFilters([sessionDriver.requestFilter])
server.setResponseFilters([sessionDriver.responseFilter])
//END: CORS跨域设置

WEBSOCKET

视频会议应用中使用的是纯WEBSOCKET交互方式。
只要对好协议、处理好心跳、超时、重连、自动断开等情况就只有业务逻辑的事情了。

routes.add(method: .get, uri: "/ws", handler: {
    request, response in
    
    WebSocketHandler(handlerProducer: {
        (request: HTTPRequest, protocols: [String]) -> WebSocketSessionHandler? in
        return WebSocketsHandler()
    }).handleRequest(request: request, response: response)
})
import PerfectLib
import PerfectWebSockets
class WebSocketsHandler: WebSocketSessionHandler {
    // 连接建立后handleSession立即被调用
    func handleSession(request: HTTPRequest, socket: WebSocket) {

        // 收取文本消息
        socket.readStringMessage {
            // 当连接超时或网络错误时数据为nil,以此为依据关闭客户端socket, 清理相关链接的缓存数据
            if let string = string {
              print("recv: \(string)")          
            } else {
              socket.close()
            }
        }
}

存储

redis

redis比较适合存储简单数据,我将redis作为搜集和验证代理服务器的结果存储

import Foundation
#if os(Linux)
    import Glibc
#endif
import SwiftRedis

let redisHost:String = "localhost"
let redisPort:Int32 = 6379

class RedisClient {
    static let shared = RedisClient()
    let redis = Redis()
    var isConnected:Bool = false
    
    let logger = Logger.shared
    
    func connect(callback:@escaping (_ status:Bool)->()) {
        redis.connect(host: redisHost, port: redisPort) { (redisError: NSError?) in
            if let error = redisError {
                logger.log(.error, msg: "connect redis failed:\(error)")
            }
            callback(redis.connected)
        }
    }
}
//更新redis数据
    private func updateRedis(proxy:ProxyInfo, type:String, status:Bool,callback:@escaping (_ status:Bool)->()) {
        if !redisClient.redis.connected {
            self.logger.log(.error, msg: "redis is disconnected")
            callback(false)
            return
        }
        guard let value = proxy.toJson() else {
            self.logger.log(.error, msg: "proxyToJson failed")
            callback(false)
            return
        }
        
        let key = proxy.host + ":" + String(proxy.port)
        
        if status {
            //更新检测成功的代理
            redisClient.redis.hset(type, field: key, value: value, callback: { (status, error) in
                callback(status)
            })
        } else {
            //移除检测有问题的代理
            redisClient.redis.hdel(type, fields: key, callback: { (status, error) in
                callback(status == 0 ? true:false)
            })
        }
    }

mongodb

mongodb用于包含较复杂数据结构的各类业务数据,查询起来也非常方便

MongoDB设置

import StORM
import MongoDBStORM

MongoDBConnection.host = "localhost"
MongoDBConnection.port = 27017
MongoDBConnection.database = "BookServer"

save

    func doSave() throws {
        let deleting = Book()
        
        do {
            try deleting.find(["bookId":self.bookId])
            if deleting.results.cursorData.totalRecords > 0 {
                for row in deleting.rows() {
                    try row.delete()
                }
            }
        } catch {
            throw error
        }
        
        do {
            self.id = newUUID()
            try self.save()
        } catch {
            throw error
        }
    }

search

    func doSearch() -> Book?  {
        do {
            try self.find(["bookId":self.bookId])
            if let book = self.rows().first {
                return book
            }
        } catch {
            print("doSearch failed:\(error.localizedDescription)")
        }
        return nil
    }

mongodb数据转class

let kBookCollectionName:String = "Books"
public class Book: MongoDBStORM {
    var id:String = ""
    var bookId: String = ""
    var title: String = ""      //书名
    var author: String = ""     //作者

    var lastUpdateTime:Int = 0 //最后更新时间
    override init() {
        super.init()
        _collection = kBookCollectionName
    }
    
    override public func to(_ this: StORMRow) {
        id              = this.data["_id"] as? String          ?? ""
        bookId          = this.data["bookId"] as? String       ?? ""
        title           = this.data["title"] as? String        ?? ""
        author          = this.data["author"] as? String       ?? ""
        
        lastUpdateTime  = this.data["lastUpdateTime"] as? Int  ?? 0
    }
    
    // A simple iteration.
    // Unfortunately necessary due to Swift's introspection limitations
    func rows() -> [Book] {
        var rows = [Book]()
        for i in 0..

日志

作为服务端不能没有日志,很方便,根据自己的需要自定义一下就行。

import Foundation
#if os(Linux)
    import SwiftGlibc
    import Dispatch
#endif
import PerfectLib
import PerfectLogger

enum LogLevel: Int32 {
    case trace  = 0
    case debug  = 1
    case info   = 2
    case warn   = 3
    case error  = 4
    case none   = 5
    
    func desc()->String  {
        switch self {
        case .trace:
            return "[TRACE]"
        case .debug:
            return "[DEBUG]"
        case .info:
            return "[INFO]"
        case .warn:
            return "[WARN]"
        case .error:
            return "[ERROR]"
        default:
            return "";
        }
    }
}

class Logger: NSObject {
    static let shared = Logger()

    var logFile:String = "/tmp/meeting.log"

    var logLevel:LogLevel = LogLevel.none
    
    var dateFormat:String = "YYYY-MM-dd HH:mm:ss"
    let dateformatter = DateFormatter()
    
    var isHideStdOutLog:Bool = true
    
    var ff:File?
    
    let logQueue = DispatchQueue(label: "logQueue")
    
    override init() {
        super.init()
        self.dateformatter.locale = Locale.current
    }
    
    func showLogOnStdout(_ isShow:Bool) {
        isHideStdOutLog = !isShow
    }
    
    func setLogFile(path:String) {
        logFile = path
    }
    
    func setLogLevel(level:LogLevel) {
        logLevel = level
    }
    
    func setDateFormat(format:String) {
        dateFormat = format
    }

    func log(_ level:LogLevel, msg:String) {
        if (level.rawValue >= logLevel.rawValue) {
            let formatMsg:String = currectDateDesc() + " " + level.desc() + " " + msg
            logQueue.async {
                if self.ff == nil {
                    self.ff = File(self.logFile)
                    try? self.ff?.open(.append)
                }
                let _ = try? self.ff?.write(string: formatMsg + "\n")
            }
            
        }
    }
    
    //当前日期时间描述
    private func currectDateDesc() -> String {
        //EEEE:表示星期几(Monday),使用1-3个字母表示周几的缩写
        //MMMM:月份的全写(October),使用1-3个字母表示月份的缩写
        //dd:表示日期,使用一个字母表示没有前导0
        //YYYY:四个数字的年份(2016)
        //HH:两个数字表示的小时(02或21)
        //mm:两个数字的分钟 (02或54)
        //ss:两个数字的秒
        //zzz:三个字母表示的时区

        if dateformatter.dateFormat != dateFormat {
            dateformatter.dateFormat = dateFormat
        }
        return dateformatter.string(from: Date())
    }
}

//文件写入模式
enum FileWriteMode {
    case Write, Append
    
    func cMode() -> String {
        switch self {
        case .Write: return "w+"
        case .Append: return "a+"
        }
    }
}

HttpClient

作为服务器,不可避免要从其他Http接口或站点间接获取数据。
放心,我们在客户端使用最多的URLSession现在已经可以不需要修改代码直接使用了。

代理设置

作为服务器,在作为client访问Http请求时经常会用到代理服务器

        let config = URLSessionConfiguration.default
        config.connectionProxyDictionary = [AnyHashable: AnyObject]()
        config.connectionProxyDictionary?[kCFNetworkProxiesHTTPEnable as String] = NSNumber(value: 1)
        config.connectionProxyDictionary?[kCFStreamPropertyHTTPProxyHost as String] = proxyHost as NSString
        config.connectionProxyDictionary?[kCFStreamPropertyHTTPProxyPort as String] = NSNumber(value: proxyPort)
        config.connectionProxyDictionary?[kCFNetworkProxiesHTTPSEnable as String] = NSNumber(value: 1)
        config.connectionProxyDictionary?[kCFStreamPropertyHTTPSProxyHost as String] = proxyHost as NSString
        config.connectionProxyDictionary?[kCFStreamPropertyHTTPSProxyPort as String] = NSNumber(value: proxyPort)

        let session = URLSession(configuration: config)

服务器开发一定要注意的坑

swift build永远不结束,直至系统资源耗尽

在Package.swift中引入模块时遇到不同模块分别引用了同一模块的不同版本,在这种情况下swift build没有报错,但是也永远完不成。这个问题在Mac和Linux环境都会发生,千万千万注意!当时查了好久。

内存泄漏

注意一定要调用finishTasksAndInvalidate,否则会有内存泄漏,这个用xcode调试就能很明显看出来。

        let sessionTask = session.dataTask(with: request, completionHandler: { (data, resp, error) in
            guard let httpResp = resp as? HTTPURLResponse else {
                callback(nil)
                return
            }
            if (error != nil || httpResp.statusCode != 200) {
                callback(nil)
                return
            } else {
                callback(data)
            }
            session.finishTasksAndInvalidate()
        })
        sessionTask.resume()

并发

特别需要注意的是在mac环境下并发功能是没问题的,但是在Linux环境,并发数量和cpu核数成正比,记不清是核数还是2倍的核数,大家自己验证吧。

后记

因为网络小说应用的特点,有许多应用的场景在通常的情况下用得较少,在这就不一一的介绍了。
以后逐步和大家讨论多源站爬虫、HTML数据解析、大量匿名代理的使用、并发、连接池、状态管理、系统资源(并发量、数据解析、爬虫、数据存储、数据压缩、文件存储等的竞争)等等内容,容我再进步一点,省得丢人。

推荐

和我一样喜欢免费、无广告、可换源看书的iOS用户试试吧,没广告真好,再也不用因为要屏蔽广告关网络了!
AppStore搜索应用名称: 快搜神器
直达链接: https://itunes.apple.com/cn/app/%E5%BF%AB%E6%90%9C%E7%A5%9E%E5%99%A8/id1330808704?mt=8

1515663796.png

作为一个重度网络小说迷的我,会一直优化更新下去!谢谢捧场~
等到Swift在android上不是只能写hello world的时候,android的版本自然会到来~

你可能感兴趣的:(Swift语言开发App服务端)