媒体中心设计分享

更详细的内容可以查看文章结尾的PDF

媒体文件处理

magic bytes 校验
扩展名校验
文件名校验
mime校验
速率限制

图片的处理

  1. (水印颜色的优化) 颜色深浅的判断
  2. 图片中心的判断
  3. 颜色色差的聚类

音频的处理

视频的处理

http api跨域

iframe

同源策略,document.domain = tool.lu

jsonp

这种跨域实际上是在页面中动态插入一个 <script> 标签,然后在被加载的脚本中执行当前网页里的函数;显而易见,只支持GET请求。

ajax2

HTTP access control (CORS) - Cross-Origin Resource Sharing

  1. 简单请求
  2. 预检请求

http返回头

Access-Control-Allow-Origin: <origin> | *
Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
Access-Control-Max-Age: <delta-seconds>
Access-Control-Allow-Credentials: true | false
Access-Control-Allow-Methods: <method>[, <method>]*
Access-Control-Allow-Headers: <field-name>[, <field-name>]*
static const auto maxPreflightCacheTimeout = std::chrono::seconds(600);  

http请求头

Origin: <origin>
Access-Control-Request-Method: <method>
Access-Control-Request-Headers: <field-name>[, <field-name>]*

默认情况下 跨域XHR 是不会带上Cookie的,需要设置 withCredentials = true;

preflight.png
request.png

附件:mediacenter.pdf

使用phpexcel解析Excel

使用composer管理

composer require phpoffice/phpexcel
<?php

error_reporting(E_ALL);
date_default_timezone_set('Asia/Shanghai');

require __dir__ . '/../vendor/autoload.php';

// ... 注意自己赋值$file
$excel = PHPExcel_IOFactory::load($file);
$sheetCount = $excel->getSheetCount();
echo "sheet count:$sheetCount" . PHP_EOL;
foreach ($excel->getWorksheetIterator() as $sheet) {
    if ($sheet->getSheetState() === 'hidden') {
        continue;
    }
    $sheetName = $sheet->getTitle();
    echo "sheet name: $sheetName" . PHP_EOL;
    $highestColumn = $sheet->getHighestColumn();
    $highestColumnIndex = PHPExcel_Cell::columnIndexFromString($highestColumn);
    $highestRow = $sheet->getHighestRow();
    for ($row = 1; $row <= $highestRow; $row++) {
        $columns = [];
        for($col = 0; $col < $highestColumnIndex; $col++) {
            $cell = $sheet->getCellByColumnAndRow($col, $row);
            $val = $cell->getValue();
            if ($val instanceof PHPExcel_RichText) {
                $columns[] = trim($val->getPlainText());
            } else {
                $columns[] = trim($val);
            }
        }
        var_dump($columns);
    }
}

过滤隐藏的worksheet

if ($sheet->getSheetState() === 'hidden')

过滤隐藏的行

由Excel中auto filter隐藏的行

if ($sheet->getRowDimension($row)->getVisible())

获取单元格颜色

获取文字颜色

$cell->getStyle()->getFont()->getColor()->getRGB();

获取填充颜色

$cell->getStyle()->getFill()->getStartColor()->getRGB();

颜色的差异值

是这样,运营那边对不同类型的数据进行了颜色的分类,但是是多个人整理的,所以紫色淡紫色的运营那边认为是同一个东西;但是程序里面是没法判断,因为紫色的区域太大了;所以这边就需要用到颜色的差异值比对了。

| YUV |      -      |   -   |
|-----|-------------|-------|
|  Y  |     Luma    | 明亮度 |
|  U  | Chrominance |  色度  |
|  V  |    Chroma   |  浓度  |

Y'UV 的发明是由于彩色电视与黑白电视的过渡时期。黑白视讯只有 Y(Luma,Luminance)视讯,也就是灰阶值。到了彩色电视规格的制定,是以 YUV/YIQ 的格式来处理彩色电视图像,把 UV 视作表示彩度的 C(Chrominance或Chroma),如果忽略 C 讯号,那么剩下的 Y(Luma)讯号就跟之前的黑白电视讯号相同,这样一来便解决彩色电视机与黑白电视机的相容问题。

|  Lab  |  -   |   -   |
|-------|------|-------|
|   L   | Luma | 明亮度 |
|   a   |   a  |   -   |
|   b   |   b  |   -   |

RGB -> XYZ -> Lab -> delta E

RGB to closest predefined color
color distance
RGB到Lab的转换

| RGB |   -   |  -  |
|-----|-------|-----|
|  R  |  Red  | 红色 |
|  G  | Green | 绿色 |
|  B  | Blue  | 蓝色 |

php默认函数 imagecolorclosest // RGB几何距离,由于RGB空间不是颜色感知连续的,所以效果没有Lab'的好

这边有个实现好的颜色空间转换的代码 hasbridge/php-color

slim框架中pjax的实现

pjax = pushState + ajax

由于这是一个比较通用的组件,所以我把它写成了slim的Middleware (如果不知道middleware是什么或者怎么写,可以看这里)

大概原理是在slim框架渲染之后,输出之前,对dom结构进行分析,摘取出pjax所需要的部分,然后将这部分的内容作为response返回给浏览器的ajax请求

预览: 码农文库 点击分页可以看到效果

$app->add(new \Support\PjaxMiddleware());
<?php
namespace Support;

use Slim\Middleware;
use Slim\Http\Request;
use Slim\Http\Response;
use Symfony\Component\DomCrawler\Crawler;
class PjaxMiddleware extends Middleware
{
    public function call()
    {
        $request = $this->app->request();
        $response = $this->app->response();
        $this->next->call();
        if (!$request->headers('X-PJAX') || $response->isRedirect()) {
            return;
        }
        $this->filterResponse($response, $request->headers('X-PJAX-CONTAINER'))->setUriHeader($response, $request);
    }
    private function filterResponse(Response $response, $container)
    {
        $crawler = new Crawler($response->getBody());
        $response->setBody($this->makeTitle($crawler) . $this->fetchContents($crawler, $container));
        return $this;
    }
    private function makeTitle(Crawler $crawler)
    {
        $title = $crawler->filter('head > title')->html();
        return "<title>{$title}</title>";
    }
    private function fetchContents(Crawler $crawler, $container)
    {
        $content = $crawler->filter($container);
        if (!$content->count()) {
            $this->app->stop();
        }
        return $content->html();
    }
    private function setUriHeader(Response $response, Request $request)
    {
        $query = $request->get();
        unset($query['_pjax']);
        $response->header('X-PJAX-URL', $request->getResourceUri() . '?' . http_build_query($query));
    }
}

slim框架接入pysh

由于实际开发中需要查看接口返回值结构和实际的数据样例,每次都写controller再访问url看下是比较麻烦的。

所以如果能像 php -a 这样交互的方式调用网站应用中定义的model和function的话,将会极其的方便。

目录结构如下:

app/
public/
tinker

app为网站具体逻辑实现的部分
public下面有1个index.php为网站的入口
tinker就是我们需要接入pysh,实现REPL的文件了

由于Slim里面需要PATH_INFO的值,但是命令行运行的时候,这个值是不存在的,所以需要在代码中mock一下;代码如下:

#!/usr/bin/env php
<?php

require __DIR__ . '/vendor/autoload.php';

$app = require __DIR__ . '/app/bootstrap.php';

$app->environment = \Slim\Environment::mock([
    'PATH_INFO' => '/playground'
]);

$app->notFound(function () use ($app) {
    $path = $app->environment['PATH_INFO'];
    echo "Cannot route to $path" . PHP_EOL;
    $app->stop();
});

$app->error(function (\Exception $e) use ($app) {
    echo $e . PHP_EOL;
    $app->stop();
});

$app->any('/playground', function () {
    $config = new \Psy\Configuration([
        'tabCompletion' => true,
        'tabCompletionMatchers' => [
            new \Psy\TabCompletion\Matcher\ClassNamesMatcher,
            new \Psy\TabCompletion\Matcher\ClassMethodsMatcher,
            new \Psy\TabCompletion\Matcher\ClassAttributesMatcher,
            new \Psy\TabCompletion\Matcher\FunctionsMatcher,
        ],
    ]);
    $shell = new \Psy\Shell();
    $shell->run();
});

$app->run();

网易式评论箱的实现

预览

QQ20160416-0.png

实现

基础

  1. 表的设计
  2. 前端的实现

由于每个回复的展示都需要完整的引用路径,我们需要一个字段来记录本条回复所回复的回复 quote_id,在一个列表中如果每次都递归获取引用的评论,性能上会有很大的瓶颈,所以我们冗余一个字段,记录本条回复的引用路径 quote_path

CREATE TABLE `pre_comments` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `article_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `quote_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `quote_path` varchar(255) NOT NULL DEFAULT '' COMMENT '记录最近的20个值',
  `user_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `username` char(15) NOT NULL DEFAULT '',
  `content` varchar(1024) NOT NULL,
  `up` bigint(20) NOT NULL DEFAULT '0',
  `down` bigint(20) NOT NULL DEFAULT '0',
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  `deleted_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_article_id` (`article_id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

数据展示流程:

  1. 获取最新的10条评论
  2. 获取与最新的10条评论相关被引用的评论,最大层级不超过20,超过20的使用查看更多跳转到单独的评论页面
  3. 组装数据,返回json
  4. Reactjs渲染数据

Untitled.png

用伪代码可以表示为如下结构

<CommentBox>
  <CommentList>
    <CommentItem>
      <CommentQuote>
        <CommentQuote>
          <CommentToolBar>
            <CommentForm></CommentForm>
          </CommentToolBar>
        </CommentQuote>
        <CommentToolBar>
          <CommentForm></CommentForm>
        </CommentToolBar>
      </CommentQuote>
      <CommentToolBar>
        <CommentForm></CommentForm>
      </CommentToolBar>
    </CommentItem>
  </CommentList>
  <CommentForm></CommentForm>
</CommentBox>

优化

  1. 分库分表
  2. 通用计数组件
  3. 缓存
  4. 静态化

通过查询场景来决定分库分表的策略

  1. 根据articleId查询最新的评论
  2. 根据articleId 和 commentId更新计数
  3. 根据articleId 和 quoteId写入新的评论
  4. 展示某个用户(userId)所有的评论

其中有两个分表的路由key articleIduserId

未完待续

使用javassist修改idea mybatis插件

反编译查看源码

# cd ~/Library/Application Support/IntelliJIdea15/mybatis_plus/lib/
# cd ~/Library/Application Support/IntelliJIdea2016.2/mybatis_plus/lib/
# cd ~/Library/Application Support/IntelliJIdea2016.3/mybatis_plus/lib/
cd ~/Library/Application Support/IntelliJIdea2017.1/mybatis_plus/lib/

使用 JD-GUI 打开 mybatis_plus.jar,查看源码:

Screenshot 2016-03-05 at 12.19.56.png
Screenshot 2016-03-05 at 12.19.53.png

修改验证逻辑

使用javassist修改字节码

import javassist.*;

class MyBatisPlusCrack {

    public static void main(String[] args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass c = pool.get("com.seventh7.mybatis.util.JavaUtils");
        CtMethod m = c.getDeclaredMethod("refValid");
        m.setBody("{ validated = true; valid = true; return valid; }");
        c.writeFile();

        CtClass cc = pool.get("com.seventh7.mybatis.service.JavaService");
        CtMethod mm = cc.getDeclaredMethod("stop");
        mm.setBody("{ return; }");
        cc.writeFile();
    }

}
# 运行
javac -classpath ".:javassist.jar:mybatis_plus.jar" MyBatisPlusCrack.java
java -classpath ".:javassist.jar:mybatis_plus.jar" MyBatisPlusCrack

此时会在当前目录下生成修改过的两个类文件,使用压缩软件替换jar包中的这两个文件;重启idea。

jar uvf mybatis_plus.jar \
    com/seventh7/mybatis/service/JavaService.class \
    com/seventh7/mybatis/util/JavaUtils.class

在JAVA中实现PHP的gzuncompress

背景

在系统改造的时候,从php迁移到java;由于php中为了节省redis的内存,对缓存的数据做了 gzcompress 处理;为了能读取出数据,有两套方案:

  1. 刷数据,将redis中老的数据清理掉,去掉 gzcompress 的步骤(缺点:刷数据的时间,和读取代码上线的时间点无法吻合;数据的写入入口比较多,容易遗漏)
  2. java中读取的时候可以进行 gzuncompress

一些知识

知道这些知识候就能避免我在实现过程中遇到的很多问题。

PHP中的 gzcompress

This function compresses the given string using the ZLIB data format.
Note:
This is not the same as gzip compression, which includes some header data. See gzencode() for gzip compression.

一直以为 gzcompress 就是 gz 的压缩,php中使用的 zlib 来压缩,压缩完的结果中携带了头信息,直接使用 gz 解压是不认这种格式的。

JAVA中的 new String(byte[])

java.lang.StringCoding.StringDecoder 当在编码 byte[] 不能处理的时候会进行一些处理;所以说 (new String(compressedByte)).getBytes()compressedByte 并不一定会完全一样。

说到这里就可以看下 jedis 提供的接口了,刚开始我是使用的 String get(String key),于是由于上面的原因,当我用这个返回值 getBytes() 的时候就已经发生了变化。正确的使用方法应该是使用 byte[] get(byte[] key),由于比较繁琐,封装一下。

实现

    public static String get(Jedis jedis, String key) {
        byte[] byteKey = key.getBytes();
        byte[] element = jedis.get(byteKey);
        return new String(gzuncompress(element));
    }

    public static List<String> mget(Jedis jedis, List<String> keys) {
        byte[][] byteKeys = new byte[keys.size()][];
        for (int i = 0; i < keys.size(); i++) {
            byteKeys[i] = keys.get(i).getBytes();
        }
        List<byte[]> elements = jedis.mget(byteKeys);
        List<String> result = new ArrayList<>();
        for (byte[] element : elements) {
            result.add(new String(gzuncompress(element)));
        }
        return result;
    }

    public static byte[] gzuncompress(byte[] data) {
        byte[] unCompressed = null;
        ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length);
        Inflater deCompressor = new Inflater();
        try {
            deCompressor.setInput(data);
            final byte[] buf = new byte[1024];
            while (!deCompressor.finished()) {
                int count = deCompressor.inflate(buf);
                bos.write(buf, 0, count);
            }
            unCompressed = bos.toByteArray();
            bos.close();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            deCompressor.end();
        }

        return unCompressed;
    }

记一次内存泄露的debug过程

在压测 代码在线运行 工具的时候,发现当并发比较高的时候程序占用的内存会飙升,而且在中断压测之后,内存占用并没有回落。

第一个能想到的办法就是去看代码,但是大多数时候,自己写的代码,很难review出太多的问题;于是就借助golang的pprof来定位问题。

在程序中嵌入 pprof

package main

import (
    "tool.lu/sandbox-server/app"
    "net/http"
    _ "net/http/pprof"
    "strconv"
    "runtime"
)

func main() {
    debug()
    server := app.NewApp()
    server.Run(":9090")
}

func debug() {
    go func() {
        // 这边是由于通过pprof发现问题之后,加的一段debug代码;后面会讲到
        http.HandleFunc("/go", func(w http.ResponseWriter, r *http.Request) {
            num := strconv.FormatInt(int64(runtime.NumGoroutine()), 10)
            w.Write([]byte(num))
        })
        http.ListenAndServe("localhost:6060", nil)
    }()
}

通过 go tool 工具,查看内存分配最多的 top 5

go tool pprof http://localhost:6060/debug/pprof/heap
top 5

Screenshot 2016-02-07 at 17.55.39.png

查看代码,发现是 goroutine, ioPipe 的问题,一定是使用姿势出了问题:

Screenshot 2016-02-07 at 17.56.40.png

于是便有了上面的那段代码,curl http://localhost:6060/go,查看当前 go routine 的数量;于是猜测是因为 ioPipe 没有正确的关闭,引起 go routine 大量的产生,但是没有退出,耗费大量的内存;于是在异常退出前,主动关闭 ioPipe 的Reader,至此问题解决。

压测验证

本机

wrk -t5 -c20 -d10000s -s post.lua http://tool.lu

服务器

Screenshot 2016-02-07 at 16.24.24.png

curl http://localhost:6060/go

总结

这是一个很小的bug,由于写代码的时候不仔细,return之前没有关闭资源造成,但却要花费不少的力气去解决;对语言自己提供的工具链需要熟悉在熟悉,这样不管在解决问题或者避免问题的时候,都能节省很多的时间。

JAVA WEB乱码解决

设置环境变量

export $LANG="UTF-8"

设置tomcat接收GET参数时候的编码 server.xml (tomcat中的配置文件)

<Connector
    ....
    URIEncoding="UTF-8">

设置接收POST参数时候的编码

web工程中web.xml的设置

必须要放在所有filter之前

    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

使用CLion开发PHP扩展

首先使用php源码中的命令生成一个扩展结构

./ext_skel --extname=hank
cd hank
phpize
./configure

创建CMakeLists.txt

由于CLion目前还不支持MakeFile文件,所以要使用CMakeLists.txt进行中转

cmake_minimum_required(VERSION 3.3)
project(hank)

add_custom_target(makefile COMMAND make && make install
        WORKING_DIRECTORY ${PROJECT_SOURCE_DIR})

设置编译环境和调试脚本

QQ20151106-0.png

至此基本全部搞定,可以直接下断点,以debug模式进行调试

对于读多写少的少量数据的缓存优化

前提:数据量比较少,并且读多写少,实时性要求不高的数据。

优点

  1. 访问本地cache节省了网络开销,减少中心cache集群(redis)的压力
  2. 正常的web机器内存利用率较低,减少web机内存资源的浪费

类库的选择

google guava cache

  1. LRU失效机制
  2. 流畅的api接口
  3. 使用内存缓存
  4. 配套stats接口

流程

未命名文件.png

  1. [被动失效] controller层访问cache,若获取不到数据,从db中获取数据,并刷到cache
  2. [主动失效] 当db数据变更时,主动失效缓存;注意:这里cache是在每台web机器都有一份,所以每台机器都需要刷一遍;所以我们需要一个配置中心;在web机器监听配置中心的变化,然后刷新各自机器的cache
  3. 定时将本机的cache的 命中率,量 上报到监控系统

主动失效机制

  1. 实时性要求较高的可以订阅mysql的binlog
  2. 实时性要求不高的可以定时跑crontab

casperjs兼容reactjs截图

由于phantomjs使用的webkit内核版本较低,不支持es5;自然phantomjs无法正常打开reactjs的页面;这里可以使用es5-shim.js 来兼容。

casperjs是可以使用clientScripts将 es5-shim.js 插入页面中;但是这个插入是在页面的加载完之后;我们需要的是在页面的最前面插入 es5-shim.js;于是:

casper.options.onPageInitialized = function() {
    casper.page.injectJs("es5-shim.js");
};

[开源] kaka 咔咔

项目地址:https://github.com/xiaozi/kaka
下载地址:https://github.com/xiaozi/kaka/releases

依赖

  1. nsq
  2. casperjs
  3. phantomjs

安装

  1. 将 .env.example 拷贝为 .env
  2. 修改 .env, 填写信息
  3. 运行
./kaka

用法

只需要将消息塞到 nsq 的 topic 中就可以了,topic 是你在 .env 文件里面设置的

消息使用 json 格式,结构如下:

{
    "url": "http://tool.lu/",
    "target": "/data/screenshots/WrTSV5zbkHPCqU6t.png",
    "path": "screenshots/WrTSV5zbkHPCqU6t.png",
    "device": "mac"
}

url: (必须) 需要截图的url

target: (必须) 截图在服务器上的保存的绝对路径

path: (可选) 上传到七牛云的路径,不填则不上传

device: (可选) 目前只支持 “mac" 这个值

FAQ

  1. 使用casperjs截出优雅的图片
  2. 多种网络环境的处理

在每台机器上都配置一个kaka,然后让他们从不同channel的订阅

  1. 不想上传到七牛云

消息中的path留空就好了

开发依赖

go get -u github.com/joho/godotenv
go get -u github.com/qiniu/api.v7
go get -u github.com/bitly/go-nsq

Spring RequestParam过于智能

POST到后端的数据是这样的

keywords[]: 关键词1,关键词2
keywords[]: 关键词3,关键词4
@RequestParam(value = "keywords[]") List<String> keywords;

然而经过Spring的RequestParam处理之后,却变成了 ["关键词1", "关键词2", "关键词3", "关键词4"];对于 Spring 这种过分聪明的行为我表示很呵呵~

防止这种解析的办法就是覆盖掉原有的converters;不使用英文逗号为分隔符。

<mvc:annotation-driven conversion-service="conversionService"/>

<bean id="conversionService" class="org.springframework.context.support.ConversionServiceFactoryBean">
    <property name="converters">
        <set>
            <bean class="lu.tool.util.StringToArrayConverter" />
        </set>
    </property>
</bean>
package lu.tool.console.util;

import org.springframework.core.convert.converter.Converter;
import org.springframework.util.StringUtils;

import java.util.Arrays;
import java.util.List;

/**
 * Created by xiaozi on 8/12/15.
 */
public class StringToArrayConverter implements Converter<String, List<String>>{
    @Override
    public List<String> convert(String source) {
        return Arrays.asList(StringUtils.delimitedListToStringArray(source, ";"));
    }
}