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<String, Any> = [:]
        
        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<String,Any>?) {
        response.setHeader(.contentType, value: "application/json")
        response.status = .ok //200
        
        var bodyDict:Dictionary<String,Any> = 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 += "<html><body>\n"
    body += "<form action=\"/upload\" method=\"post\" enctype=\"multipart/form-data\">"
    body += "<label>File1:</label> <input type=\"file\" name=\"filetoupload\" id=\"file\" /><br/>"
    body += "<input type=\"submit\"/>"
    body += "</form>"
    body += "</body></html>\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..<self.results.rows.count {
            let row = Book()
            row.to(self.results.rows[i])
            rows.append(row)
        }
        return rows
    }
  }

日志

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

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的版本自然会到来~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335