2023年4月25日 星期二

Java EE 使用 Spring 將非根目錄的 url pattern 送給 DispatcherServlet 時要注意的事情 - 使用 RequestMappingHandlerMapping 的 alwaysUseFullPath 設定

在 Java EE 使用 Spring 時,雖然大部份情況是在專案的 web.xml 
裡設定

<url-pattern>/</url-pattern>

把所有的 url request 送進 DispatcherServlet 去處理,
但有時我們只想把某個 url pattern 之下的 url 送給 DispatcherServlet 處理 (例如你可能有多個 DispatcherServlet,或是想在沒使用 Spring 的專案加進有使用 Spring 的 API url),
這時我們可能會這樣設,例如:

<url-pattern>/api/*</url-pattern>

此例代表符合 /api/* 的 url 才會交給 DispatcherServlet 處理,如下範例:

<servlet>
		<servlet-name>dispatcher</servlet-name>
	    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
	    <init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value>/WEB-INF/mvc-config.xml</param-value>
		</init-param>
	    <load-on-startup>1</load-on-startup>
	</servlet>
	<servlet-mapping>
	    <servlet-name>dispatcher</servlet-name> 
	    <url-pattern>/api/*</url-pattern>
	</servlet-mapping>
這時你可能會發現 /api/test 的 url request 無法進入到以下 Controller 中而回應 404 No mapping for GET /api/test:
package test;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class SpringControllerTest {
	
	@RequestMapping(value="/api/test" , method = {RequestMethod.GET})
	public @ResponseBody String login(HttpServletRequest request, HttpServletResponse httpResp) throws Exception{
		System.out.println("Hello");
		return "Hello";
	}
}

這是因為根據 Spring MVC 的 Handler mappings 規則

alwaysUseFullPath
if this property is set to true, Spring will use the full path within the current servlet context to find an appropriate handler. If this property is set to false (the default), the path within the current servlet mapping will be used. For example, if a servlet is mapped using /testing/* and the alwaysUseFullPath property is set to true, /testing/viewPage.html would be used, whereas if the property is set to false, /viewPage.html would be used.

,當 alwaysUseFullPath 參數值是預設的 false 時,
DispatcherServlet 會將 url request 扣掉 web.xml 的 <url-pattern> 所設值 (此例就是 /api,/api/test 扣掉 /api 後是 /test),
然後才會跟 @RequestMapping 所設的 value 值做比對,
也就是此時 @RequestMapping 的 value 需要設成 /test 才會比對成功。

如果要比對 @RequestMapping value="/api/test" 成功的話, url request 需要是
/api/api/test 才行。

上例是 alwaysUseFullPath = false (default) 的情況,
如果這次我們把 alwaysUseFullPath 設成 true 的話,
DispatcherServlet 就不會將 url request 扣掉 web.xml 設定的 <url-pattern> 值,
這時 /api/test 的 url request 就可以被比對到而進入 Controller 中了,
設定 alwaysUseFullPath=true 的方式為在 spring config 檔中加下如下設定:

<bean id="requestMappingHandlerMapping" class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping">
       <property name="alwaysUseFullPath" value="true"/>
    </bean>  

參考資料:

  1. 16. Web MVC framework - 16.4 Handler mappings - alwaysUseFullPath

2023年2月9日 星期四

使用 docker-compose 建立 Redis Cluster

 先建立如下的資料結構:

專案目錄名為 "Redis Cluster Test",
裡面放了 docker-compose 所需的 docker-compose.yml 檔和一個名為 redis 的資料夾,
在 redis 的資料夾中放置 Redis node 所需的 redis.conf 客制設定檔
和 docker service 的 Dockerfile 設定檔。

以下是各檔的內容 (以 Redis Cluster Test 為專案根目錄):

/redis/Dockerfile:
FROM redis
COPY redis.conf /usr/local/etc/redis/redis.conf 
CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ]
裡面把 redis.conf 放至 Docker container 中,並指定了使用 redis.conf 執行 redis-server 啟動 Redis 服務。

/redis/redis.conf:
cluster-enabled yes
裡面的內容很簡單,只有一行,使用 cluster-enabled yes 開啟 Cluster 功能。

/docker-compose.yml:
version: '3.4'
services:
    redis-node-1:
        build: ./redis 
        container_name: redis-node-1
        ports: 
            - 6379:6379 
            - 16379:16379 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6379", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-2:
        build: ./redis 
        container_name: redis-node-2
        ports: 
            - 6380:6380 
            - 16380:16380
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6380", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-3:
        build: ./redis 
        container_name: redis-node-3
        ports: 
            - 6381:6381 
            - 16381:16381 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6381", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-4:
        build: ./redis 
        container_name: redis-node-4
        ports: 
            - 6382:6382 
            - 16382:16382 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6382", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-5:
        build: ./redis  
        container_name: redis-node-5
        ports: 
            - 6383:6383 
            - 16383:16383 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6383", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-node-6:
        build: ./redis  
        container_name: redis-node-6
        ports: 
            - 6384:6384 
            - 16384:16384 
        entrypoint: [redis-server, /usr/local/etc/redis/redis.conf, --port,"6384", --cluster-announce-ip, "<your server IP or hostname>"]
    redis-cluster-creator:
        image: redis
        container_name: redis-cluster-creator
        entrypoint: [/bin/sh, -c, 'echo "yes" | redis-cli --cluster create host.docker.internal:6379 host.docker.internal:6380 host.docker.internal:6381 host.docker.internal:6382 host.docker.internal:6383 host.docker.internal:6384 --cluster-replicas 1']
        depends_on: 
          - redis-node-1 
          - redis-node-2
          - redis-node-3 
          - redis-node-4
          - redis-node-5 
          - redis-node-6
在 docker-compose.yml 中,設置了 6 個 Redis node 作為 Redis Cluster 之用,
6 個 Redis node 全部都是使用同一個 Dockerfile 和 redis.conf 所建構出來的,
這邊要注意的是 ports 需要向外開放兩個 port,
第一個 port 是用來跟 client 端通信的,例如 6379、6380 等,這是我們平常要登入 Redis 時會用到的 port,暫且稱做 client port 好了。
第二個 port 官方名字叫做 Cluster bus port, 是用來讓 Redis node 跟 node 之間通信用的,預設為 client port 再加上 10000,不過也可以在 redis.conf 檔裡用 cluster-port 參數來修改,
可參考官方的說明: Scaling with Redis Cluster 。

<your server IP or hostname> 請用 docker 所在的 server host IP 或 hostname 來取代,
測試時可用 host.docker.internal (在 docker container 中可用此 hostname 連上 server host) 來測試 (實務上請用真正的 IP 或 hostname)。

redis-cluster-creator 只是一個用來執行完 Redis Cluster 設置建立 Cluster 後就關掉的 docker service,
使用 depends_on 等待各 Redis node 啟動完成後,便會執行以下命令:
echo "yes" | redis-cli --cluster create host.docker.internal:6379 host.docker.internal:6380 host.docker.internal:6381 host.docker.internal:6382 host.docker.internal:6383 host.docker.internal:6384 --cluster-replicas 1

以上命令相當於是先執行了
redis-cli --cluster create host.docker.internal:6379 host.docker.internal:6380 host.docker.internal:6381 host.docker.internal:6382 host.docker.internal:6383 host.docker.internal:6384 --cluster-replicas 1

再執行手動輸入的 yes,因為 redis-cli --cluster create 執行中會詢問
Can I set the above configuration? (type 'yes' to accept): 
需要手動輸入 yes 才能繼續下去。

redis-cli --cluster create 是建立 cluster 的指令,它將各 Redis node 連結起來成為 Cluster,
參數意義如下:
redis-cli --cluster create <node-1 IP or hostname:<port> <node-2 IP or hostname:<port> ....<可多個 Redis node>...... --cluster-replicas <一個 Redis master node 要有幾個依附的 Redis slave node>

它會自己選擇哪些要做為 Redis master node,哪些要做為 Redis slave node。

參考資料:

  1. Redis - Official Image | Docker Hub
  2. Redis configuration file example | Redis
  3. Scaling with Redis Cluster
  4. Redis|用 Docker 架設 Redis Cluster

2023年2月8日 星期三

Redis 好用指令紀錄

#檢查 Cluster 情況 (未登入 Redis node 時):

redis-cli --cluster check <redis node domain or IP>:<port>

範例:

redis-cli --cluster check xxx.xxx.xxx:6379

修復 Redis Server Cluster Slot 分配不正常等 node 問題:

redis-cli --cluster fix xxx.xxx.xxx:6379

回應範例(以下為三組 Master node 和 Slave node,實際上可能會有更多組):

xxx.xxx.xxx:6379 (1ba418d4...) -> 4 keys | 5461 slots | 1 slaves.
xxx.xxx.xxx:6381 (4131a66e...) -> 4 keys | 5461 slots | 1 slaves.
xxx.xxx.xxx:6380 (ba5a4554...) -> 4 keys | 5462 slots | 1 slaves.
[OK] 12 keys in 3 masters.
0.00 keys per slot on average.
>>> Performing Cluster Check (using node xxx.xxx.xxx:6379)
M: 1ba418d421ac322943423091360c909a549b3d39 xxx.xxx.xxx:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
S: 431b199172871c4c61f4e2f48ed1b9117dd3e801 xxx.xxx.xxx:6384
   slots: (0 slots) slave
   replicates 1ba418d421ac322943423091360c909a549b3d39
S: 2fb427cf8acb3521c67c45fc2cfe1ac2a669a3c0 xxx.xxx.xxx:6383
   slots: (0 slots) slave
   replicates 4131a66e5cadfd91c48b824d5288ddb1445d8bd1
M: 4131a66e5cadfd91c48b824d5288ddb1445d8bd1 xxx.xxx.xxx:6381
   slots:[10923-16383] (5461 slots) master
   1 additional replica(s)
M: ba5a4554bc90d750de37a5da39b93dca896bab72 xxx.xxx.xxx:6380
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
S: 70712afa54623608772bafd1926305e895d7d076 xxx.xxx.xxx:6382
   slots: (0 slots) slave
   replicates ba5a4554bc90d750de37a5da39b93dca896bab72
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
在回應中, "M" 代表 Master node、"S" 代表依附於 Master node 的 "Slave node",
Slave node 的 replicates 值指出了它依附的是哪一個 Master node

#登入 Redis node (會進入 Redis 互動 command line 模式,"-c" 代表 Cluster Mode):

redis-cli -h xxx.xxx.xxx -p 6379 -c

#查看 Cluster 狀態(登入 Redis node 後):

cluster info

回應範例:

cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:3649
cluster_stats_messages_pong_sent:3775
cluster_stats_messages_sent:7424
cluster_stats_messages_ping_received:3770
cluster_stats_messages_pong_received:3646
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:7421
total_cluster_links_buffer_limit_exceeded:0
#啟動 Redis node:
redis-server <Redis config 檔的位置> --port <Redis node 的 port> --cluster-announce-ip "<Redis node 的 IP 或 hostname>"
範例:
redis-server /usr/local/etc/redis/redis.conf --port "6379" --cluster-announce-ip "xxx.xxx.xxx"
#建立 Redis Cluster:
redis-cli --cluster create <Redis node-1 IP or hostname:<port> <Redis node-2 IP or hostname:<port> ...<可多個 Redis node>....... --cluster-replicas <一個 Redis master node 要有幾個依附的 Redis slave node>
範例:
redis-cli --cluster create xxx.xxx.xxx:6379 xxx.xxx.xxx:6380 xxx.xxx.xxx:6381 xxx.xxx.xxx:6382 --cluster-replicas 1

#清除cluster中所有 node 的 key資料 (等同於對每個 cluster 中的所有 node 執行 flushall)

redis-cli --cluster call <cluster 中的任一node ip 或 hostname>:<Redis node 的 port> flushall

範例:

redis-cli --cluster call xxx.xxx.xxx:6379 flushall

2022年12月11日 星期日

CSS display grid 和 flex 對於子元素 width 的差別

 CSS 的 display grid 和 flex 對於子元素的 width 行為不太一樣,
主要差別是:

Grid:
先以父元素的寬度計算子元素均分後的寬度,子元素各自再用 width 屬性計算出自己最後的寬度,width 是百分比時,最後寬度是以子元素分到的寬度再乘以百分比得到。

Flex:
先以父元素的寬度計算各子元素的寬度,width 是百分比時,寬度是父元素的寬度乘以百分比來得到,之後再用 Flex 排版的方式決定是否寬度要進行延展、縮小或不變 ( 例如 flex-grow、flex-shrink、flex-basis 等設定)。

以下用實際例子來展示:

HTML:

<div class="grid stripes-background">
  <div>grid item1</div>
  <div>grid item2</div>
</div>

<div class="flex stripes-background">
  <div>flex item1</div>
  <div>flex item2</div>
</div>

CSS:

.stripes-background {
  background-image: linear-gradient(45deg, #000 25%, transparent 25%, transparent 50%, #000 50%, #000 75%, transparent 75%, #fff);
  background-size: 50px 50px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}

.flex {
  background-color: yellow;
  display: flex;
}

.grid div, .flex div {
  border: 1px solid black;
  width: 50%;
  background-color: rgba(0, 256, 0, 0.7);
}

JsFiddle 的線上範例

範例中有兩個區塊,分別使用了 display grid 和 flex,並在父元素和子元素上畫上背景來較好的分辨子元素寬度的分配。

可以看到子元素被設定了 width: 50%;,

在 grid 佈局中,子元素先以父元素的寬度用 flex 佈局計算好了應得的寬度,也就是
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
,然後才用應得的寬度乘上 50% 得到最後的寬度。

而 flex 佈局中,子元素先以父元素的寬度乘上 50% 得到應得的寬度,之後才進行 flex 佈局,而此例剛好沒有要對子元素寬度進行增縮。

簡單地來說,

Grid 是先在父元素把分配的空間格線畫好,再把子元素放進各空間中,此時子元素的 100% 寬度就是所處空間的寬度,最後各子元素再根據自己的 width 屬性設定計算寬度。

而 Flex 是子元素先根據自己的 width 屬性計算寬度,100% 寬度就是父元素的寬度,接著再用 Flex 進行排版決定各子元素在空間上的排列位置,最後再看有沒有要用 flex-grow、flex-shrink、flex-basis 等設定增縮子元素的寬度。

2022年11月27日 星期日

練習自製簡單的 Javascript Promise 物件

繼  jQuery的Callbacks 及 jQuery之Deferred的原理(參看源碼) 兩篇文後,
今天想以自己的想法參考兩篇文來自製一個簡單的、API 介面仿照
Javascript 原生 Promise 物件的 MyPromise 物件,
主要是希望能在自己實作的過程中,能夠更理解對程式的邏輯及設計思路。

在實作之前,首先要來簡單的介紹 Javascript 原生的 Promise:
Promise 的功能及性質類似 JQuery 的 $.Deferred() 及 Angularjs 的 $q 等,
在早期瀏覽器還沒有實作 Promise 物件時,JQuery 就已經自己實作了 Deferred ,
可以用來方便地管理非同步異步操作。

建立 Promise 物件的方法是 new 關鍵字,例如:

var promise = new Promise(function(resolve, reject){
    setTimeout(function(){
    	var resolvedData = 1;
    	resolve(resolvedData);
    }, 1000);
});

Promise 的建構式 (constructor) 接受一個函式,函式可以得到兩個各叫做 resolve 和 reject 的函式,
當異步操作完成後,可以執行 resolve(resolvedData) 函式,並可以送入一個想要傳遞給下一個異步操作的 resovledData 資料作參數。

當 resolve() 被執行後,可以以串接的方式用 then() 函式來串接下一個想要執行的異步操作,
例如:

var promise = new Promise(function(resolve, reject){
	setTimeout(function(){
		console.log(1);
		resolve(2);
	}, 3000);
})
.then(function(resolvedData){
	setTimeout(function(){
		console.log(resolvedData);
		resolve(3);
	}, 2000);
})
.then(function(resolvedData){
	setTimeout(function(){
		console.log(resolvedData);
		resolve();
	}, 1000);
});

上面的例子會在 3 秒後在 console 印出 1,之後再 2 秒印出2,再 1 秒印出3

原生的 Promise 實際上瀏覽器是如何實作它們的我並不清楚,
不過在這篇文中我嘗試試著以自己的想法模擬實現部份的介面功能來作為練習,
主要實現的部份有:

  1. 自己實作的 Class 名稱名命為 MyPromise
  2. 能夠以
    new Promise(function xxxFunction(resolve){
    	//do somthing
    	resolve(resolvedData);
    }).then(function(resolvedData){
    	//do somthing
    	return new Promise(function(resolve){
    		//do somthing
    	});
    }).then(function(resolvedData){
    	//do somthing
    	return new Promise(function(resolve){
    		//do somthing
    	});
    })
    的方式建立 Promise 的物件,並以傳入 xxxFunction(resolve) 的 resolve 函式來 resolve Promise,其中可以傳入 resolvedData 資料到下一個 Promise 中,下一個 Promise 用 then(fujction (rsolvedData)) 來接收 resolvedDatat 並可以繼續回傳新的 Promise 來以同樣的方式用 resolve 串接到下個 Promise。
  3. 只實現 resolve 相關的部分,忽略了 reject 的部份,也省略了一些如 Promise.all, Promise.race 等的函式實作。
  4. 實現方式參考了 JQuery 的 Deferred 物件實作思路,使用一個佇列列隊 (queue) 的方式來實現,其中:
    resolve(resolvedData) 相當於是執行佇列中的所有函式,並且之後有函式被添加至佇列時也會馬上被執行,resovledData 會被儲存起來供要執行佇列中的函式時作為輸入用。
    then() 被執行時會回傳一個 Promise,這裡暫且稱為 Promise2 ,
    Promise2 可以繼續地用 then() 往下串接。
    then() 的行為是把要執行的函式添加至佇列中,
    這裡暫稱要執行的函式為 thenFun(),
    thenFunc() 被執行後會回傳另一個 Promise,這裡暫且稱為 Promise3,
    我們會把 Promise2 的 resolve() 函式加進 Promise3 的佇列中,
    這樣在 Promise3 在 thenFunc() 被執行 reolsve() 後,就會執行被放在 Promise3 佇列中的 Promise2 的 resolve(),
    進而讓設定在 Promise2 佇列中的函式被執行。
下面是實現的程式碼和使用的範例:
function MyPromise(callbackFunc){
  var promise = this;
  
  promise.status = "pending"; //pending, resolved
  promise.callbackQueue = [];
  promise.resolvedData = null;
  
  promise.addCallbackFuncToQueue = function(callbackFunc){
  	promise.callbackQueue.push(callbackFunc);
    if (promise.status == "resolved"){
    	promise.fireCallbackFuncInQueue();
    }
  }

  promise.resolve = function(resolvedData){
  	if (promise.status != "resolved"){
      promise.resolvedData = resolvedData;
      promise.status = "resolved";
      promise.fireCallbackFuncInQueue();
    }
  }
  
  promise.fireCallbackFuncInQueue = function(){
    while(promise.callbackQueue.length > 0){
      var callbackFuncToFire = promise.callbackQueue.shift();
      callbackFuncToFire.call(promise, promise.resolvedData);
    }
  }
  
  promise.then = function(thenCallbackFunc){
  	return new MyPromise(function(resolve){
    	promise.addCallbackFuncToQueue(function(data){
      	var returnedPromiseFromThenCallbackFunc = thenCallbackFunc.call(promise, data);
        returnedPromiseFromThenCallbackFunc.addCallbackFuncToQueue(resolve);
      });
    });
  }

  callbackFunc.call(promise, this.resolve);
}
/////////////////////////////////////////////////
new MyPromise(function(resolve){
	console.log(1);
  setTimeout(function(){
  	resolve(2);
  }, 2000);
})
.then(function(data){	
  return new MyPromise(function(resolve){
  	console.log(data);
  	setTimeout(function(){
  	resolve(3);
  }, 1000);
  });
})
.then(function(data){	
  return new MyPromise(function(resolve){
  	console.log(data);
  	resolve();
  });
});
JSFiddle 的線上範例:


參考資料:

2022年11月25日 星期五

Microfost 官方 JDBC driver 的 getMoreResults Method (int current) 不支援以 Statement.KEEP_CURRENT_RESULT = 2 做為輸入值

Java 的 java.sql.CallableStatement 類別有一個 叫做 getMoreResults(int current) 的 method,
可以傳入 int current 作為參數,
int current 的值可以使用 java.sql.Statement 的一些 static 屬性值,
例如:

Statement.CLOSE_CURRENT_RESULT = 1

Statement.KEEP_CURRENT_RESULT = 2

但要注意不是每一個 JDBC driver 都支援所有值,
例如 Microfost 官方 getMoreResults Method (int) 這篇文有說到其官方 JDBC driver

(com.microsoft.sqlserver.jdbc.SQLServerDrive)
並不支援
Statement.KEEP_CURRENT_RESULT :
    ==> KEEP_CURRENT_RESULT (not supported by the JDBC driver)




2022年11月23日 星期三

配合 Fetch 使用 MediaSource 播放 FMP4 - 影片輸出使用 jsp 或 servlet

 html 裡用 <video> 播放 MP4 影片檔時,
需要完全的讀取完影片檔才能播放,如果影片本身較長檔案大小較大時,
就會需要等待較長的時間等影片讀取完後才能開始播放,
因為 <video> 需要讀完影片檔中含有影片資訊的 moov (Movie Box) 區塊才會開始播放影片。

我們可以使用 Bento4 工具來查看影片中的檔案結構,
使用 Bento4 的 mp4dump 命令執行以下指令:

D:\Bento4\bin\mp4dump xxx_video.mp4

就可以在命令列視窗中看到影片的結構,

通常的 MP4 除了一開始的含有影片版本協議等資訊的 ftyp (File Type Box) 區塊,
接著的會是一個含有影片資訊的 moov (Movie Box) 區塊和一個含有實際影片數據的 mdat (Media Data Box) 區塊 (其他區塊可先忽略),像這樣:

ftyp + mdat + moov

mdat 和 moov 的擺放位置順序可以換,如果 moov 在 mdat 之後的話,
<video> 會需要在讀取完 moov 後才會開始播放影片。

如果想要提前 <video> 的影片開始播放時間的話,有幾種解決方法:

  1. 第一個方法是把 moov 放到 mdat 之前,
    讓 <video> 先讀取到 moov 的影片資訊,
    這樣 <video> 就可以不用等到影片完全載入完就開始播放影片,
    檔案結構會像這樣:

    ftype + moov + mdat

    使用 ffmpeg 工具執行以下指令就可以將影片的 moov 搬到 mdat 前面,可參考"ffmpeg 常用命令"這篇文章:
    D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -movflags faststart -acodec copy -vcodec copy "moov_moved_video.mp4"
    第二個方法是把 MP4 轉成 FMP4 (fragmented mp4),
  2. FMP4 跟一般的 MP4 不同,
    除了有 moov 以外,還有 moof (movie fragment) 。
    一般的 MP4 檔案結構為一個 moov 和一個 mdat,
    而 FMP4 的檔案結構把影片分割成多個片段 (fragment),
    每個片段由一組 moof 和一個 mdat 組成,
    moof 包含了後面跟著的 mdat 的資訊,
    所以 <video> 可以在讀取影片時,先對讀到的片段進行播放,再一邊繼續讀取剩餘的片段,
    檔案結構會像這樣:

    ftype + moof + mdat + moof + mdat + moof + mdat + ........

    使用 Bento4 工具的 mp4fragment 命令可以方便地將 MP4 影片轉成 FMP4,
    執行以下指令即可:
    D:\Bento4\bin\mp4fragment "normal_video.mp4" "fragmented_video.mp4"
    也可使用 ffmpeg 工具來進行 FMP4 的轉檔,以下是兩種我在網路上找到的指令,
    參數有點不同,不過親自實測都是可以成功轉檔的,
    但我使用轉出來的檔案實作邊下載邊播放時有點問題,
    有時會出現
    Uncaught (in promise) DOMException: Failed to execute 'appendBuffer' on 'SourceBuffer': The HTMLMediaElement.error attribute is not null.
    的錯誤訊息,所以我還是會先以用 Bento4 轉檔的檔案來做實作:
    D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -strict -2 -movflags frag_keyframe+empty_moov "fragmented_video.mp4"
    或
    D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -movflags empty_moov+default_base_moof+frag_keyframe "fragmented_video.mp4"

成功把影片轉成 FMP4 後,就可以配合 Javascript 的 Fetch 功能實作邊下載影片檔邊播放的功能,在這裡我要展示一下使用 Javscript 的 Fetch 來邊讀取邊播放來自 servlet (或 jsp 或 nodeJs 等任何你喜歡的 server 程式碼)  的FMP4影片二進位流 (不支援非 FMP4 的影片):

首先是專案檔案結構,其中紅框部份是主要需要的檔案,此專案是一個 Java EE Web Application 專案,使用 Tomcat server 運行:




專案中放了三個測試的影片檔如下:

  1. normal_video.mp4:
    普通的影片檔,格式如:ftyp + mdat + moov
  2. moov_moved_video.mp4:
    把 moov 放到 mdat 之前的影片檔,格式如:ftype + moov + mdat
  3. fragmented_video.mp4:
    轉檔成 FMP4 的影片檔,格式如:ftype + moof + mdat + moof + mdat + moof + mdat + ........

首先是把影片檔用二進位輸出的 servlet, GetVideoBinary.java
/testWeb/src/test/GetVideoBinary.java:
package test;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLConnection;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/GetVideoBinary")
public class GetVideoBinary extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    public GetVideoBinary() {
        super();
    }

	protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		String videoType = request.getParameter("videoType");
		if (videoType == null) {
			videoType = "";
		}
		
		String videoSourceDir = "/source";
		String videoSourceName = "normal_video.mp4";
		switch(videoType) {
			case "moov_moved_video":
				videoSourceName = "moov_moved_video.mp4";
				break;
			case "fragmented_video":
				videoSourceName = "fragmented_video.mp4";
				break;
		}
		String videoSourcePath = videoSourceDir + "/" + videoSourceName;
		
		String contentType = URLConnection.guessContentTypeFromName(videoSourceName);
		if (contentType == null) {
			contentType = "application/octet-stream";
		}
		response.setContentType(contentType);
		
		try (BufferedInputStream inputStream = new BufferedInputStream(this.getClass().getResourceAsStream(videoSourcePath));
			 OutputStream outputStream = response.getOutputStream();
			){
			byte[] buffer = new byte[1024];
			int numRead = 0;
			while ((numRead = inputStream.read(buffer)) != -1) {
				outputStream.write(buffer, 0, numRead);
			}
		}
	}

	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		service(request, response);
	}

	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		doGet(request, response);
	}

}

在 GetVideoBinary.java 中,接受了一個 "videoType" 參數,來決定要讀取哪一個影片檔做輸出。

再來是主要的測試頁, index.html
/testWeb/WebContent/readableStreamTest/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Readable Stream Test</title>

<script src="js/fmp4Player.js"></script>
<script>
	readAndPlayFMP4("fragmented_video");
</script>

</head>

<body>
	<div>
		normal_video: 
		<video muted controls autoplay playsInline style="width: 500px;" id="normal_video" src="/GetVideoBinary?videoType=normal_video"></video>
	</div>
	<div>
		moov_moved_video: 
		<video muted controls autoplay playsInline style="width: 500px;" id="moov_moved_video" src="/GetVideoBinary?videoType=moov_moved_video"></video>
	</div>
	<div>
		fragmented_video:
		<video muted controls autoplay playsInline style="width: 500px;" id="fragmented_video"></video>
	</div>
</body>
</html> 

index.html 內容非常簡單,頁面中有三個 <video>, 分別用來播放上述的三個 MP4 影片,
normal_video.mp4 和 moov_moved_video.mp4 是直接設定影片 url 在 <video> 的 src 屬性上,
而 fragmented_video 則是呼叫了 "js/fmp4Player.js" 中的 readAndPlayFMP4 函式來載入影片。

最後我們來看一下 "js/fmp4Player.js",
/readableStreamTest/js/fmp4Player.js:

function readAndPlayFMP4(videoPlayerId){
	//使用 fetch 讀取影片二進位流
	fetch('/GetVideoBinary',
		{
			method : 'POST',
			headers : {
				'Content-Type': 'application/x-www-form-urlencoded'
			},
			body: "videoType=" + videoPlayerId
		}).then(function(response) {
			var videoPlayer = document.getElementById(videoPlayerId);
	
			var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
	
			if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {
				var mediaSource = new MediaSource();
				videoPlayer.src = URL.createObjectURL(mediaSource);
				mediaSource.addEventListener('sourceopen', sourceOpen);
			} else {
				console.error('Unsupported MIME type or codec: ', mimeCodec);
			}
	
			function sourceOpen(_) {
				//console.log(this.readyState); // open
				var mediaSource = this;
				var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
				
				var dataSetReductionStatus = []; // 用來顯示目前 dataset 被拆分的狀態以利觀察 Record current data reduction status for debug
				
				var reader = response.body.getReader();
				readNextDataset();
				
				function readNextDataset(){
					// 遞迴地讀取影片的二進位流資料 Read video data recursively
					return reader.read().then(function(response){
						if (response.done) {
							//讀取完畢,已沒有可讀取的影片二進位流資料,告訴 mediaSource 所有影片資料已讀取完畢
							mediaSource.endOfStream(); //tell media source that it reaches the end of stream, all data from steam has been send.
							console.log("All video data handling complete");
							return Promise.resolve();
						}else{
							// response.value is Uint8Array trype
							dataSetReductionStatus.unshift(response.value.byteLength);
							//把這一波讀到的影片資料 (response.value) 交由 handleData() 處理
							return handleData(response.value).then(function(){
								// 這波讀取的影片資料均已處理完畢,開始讀取下一波影片資料
								console.log("A set of data (total: " + response.value.byteLength + ") handling complete");
								readNextDataset();
							});
						}
					});
				}
				
				function handleData(data){
					return new Promise(function(resolve, reject){
						// 在 sourceBuffer.appendBuffer() 之後,必須等到 updateend 事件發生後才能再進行下一次的 sourceBuffer.appendBuffer()
						// Need to wait for "updateend" event to do next sourceBuffer.appendBuffer()
						sourceBuffer.addEventListener('updateend', sourceBufferUpdateendListener);
						function sourceBufferUpdateendListener(_) {
							console.log("updateend");
							dataSetReductionStatus.shift();
							sourceBuffer.removeEventListener('updateend', sourceBufferUpdateendListener);
							resolve();
						}
						
						try {
							// 印出 dataSetReductionStatus 觀察影片資料拆分的狀態
							console.log("dataSetReductionStatus: <-- " + dataSetReductionStatus + " <--");
							console.log("Try to append: " + data.byteLength);						
							sourceBuffer.appendBuffer(data); // sourceBuffer.appendBuffer() 有可能得到 "QuotaExceededError" 例外 May catch 'QuotaExceededError' exception
							console.log(data.byteLength + " data was appended.");
					    } catch (e) {
					    	sourceBuffer.removeEventListener('updateend', sourceBufferUpdateendListener);
					        if (e.name !== 'QuotaExceededError') {
					        	throw e;
				        	}
					        console.log("QuotaExceededError!");
				        	// 嘗試進行拆分影片資料分別處理,例如此例把影片拆分成總大小(byte) 80% 和 20% 大小的兩個部份 Split data and handle each reduced part of data, for example: reduction: 80%.
							var reduction = data.byteLength * 0.8;
							
							if (data.slice(0, reduction).byteLength == 0){
								// 如果拆分後 80% 那部份大小為 0 的話,就不拆分,還是處理 100% 的影片資料,但是等一段時間例如 1 秒後再處理
								// 以遞迴方式處理資料
								console.log("Try to append " + data.byteLength + " after waiting for 1 second.");
								setTimeout(function(){
					        		handleData(data).then(resolve);
								}, 1000);
							}else{
								dataSetReductionStatus.shift();
								dataSetReductionStatus.unshift(data.slice(reduction).byteLength);
								dataSetReductionStatus.unshift(data.slice(0, reduction).byteLength);
								
								// 各別以遞迴方式處理拆分後的資料
								// Handle first part of reduced data
								console.log("Split data");
								handleData(data.slice(0, reduction))
								.then(function(){
									// Handle second part of reduced data
									return handleData(data.slice(reduction));
								})
								.then(resolve);	
							}
					    }
					});
				}
			};		
	});
}

相關的說明已寫在程式碼的註解當中,這邊再做一些說明:

  1. 程式使用了 Javascript 的 fetch() 來進行影片資料的讀取,跟 XMLHttpRequest 不同,
    XMLHttpRequest 只能在讀取完全部資料後進行資料處理,
    但 fetch() 可以使用在 callback 函式中得到的 response 用 response.body.getReader() 來得到
    ReadableStream Reader,可以多次地使用 ReadableStream Reader 的 read() 來讀取部份資料,不用一次全部讀完。
  2. 當進行 sourceBuffer.appendBuffer() 把一段影片資料放到 sourceBuffer 時,
    可能會因為 Buffer 已滿而碰到 QuotaExceededError,
    這時程式會進行資料拆分,例如以 byte 為單位,先處理前 80% 的資料,再處理後 20% 的資料,
    而如果處理各拆分的資料還是碰到 QuotaExceededError 的話,就再繼續遞迴地進行拆分處理,
    最後如果要被處理的資料只有 1 byte,則不再進行拆分,當碰到 QuotaExceededError 時,
    則等待 1 秒鐘讓 Buffer 有空間時再遞迴地進行處理。
  3. 程式使用了 Promise 來處理非同步的影片處理,
    handleData() 會回應一個 Promise 物件,
    當它被 resolve() 時代表一段由 readNextDataset() 讀取到的影片資料被處理完畢,
    因為在處理影片時,有可能會碰到 QuotaExceededError 而需要進行影片資料的拆分,
    還有在 sourceBuffer.appendBuffer() 時需要等待 updateend 事件發生後才行進行下一次的 sourceBuffer.appendBuffer(),
    這些都是非同步的行為,所以使用 Promise 可以較好的處理流程。
  4. 程式中為了方便地觀察每一次由 readNextDataset() 讀取到的影片資料,
    在 sourceBuffer.appendBuffer() 時,資料是被拆分成何種狀態,
    所以建立了一個名為 dataSetReductionStatus 的 Arry 物件來觀察,
    例如一開始要處理的資料是 1024 byte 時,
    dataSetReductionStatus = [1024]
    拆分成 819 byte 和 205 byte 時,則是
    dataSetReductionStatus = [819, 205]。


最後下面是程式執行的樣子,
normal_video.mp4 會等影片全部讀取完才會開始播放,
moov_moved_video.mp4 會在讀到 moov 時就會開始播放,
fragmented_video.mp4 會在影片檔一邊下載一邊一段段的播放各個 fragment (mdat),
連覽器的 console 則是會列印出一些 debug 訊息。



原始碼分享:

  1. readableStreamTest.zip (內含範例使用的三個 MP4 影片檔)

資原分享:

  1. Bento4-SDK-1-6-0-639.x86_64-microsoft-win32.zip
  2. ffmpeg.zip
  3. 在這邊可以免費下載容量較大片長較長的影片做測試用:
    https://www.learningcontainer.com/mp4-sample-video-files-download/
    我範例使用的影片是:
    https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-Video-File-For-Testing.mp4

參考資料:

  1. 5分钟入门MP4文件格式
  2. MediaSource错误:Failed to execute"endOfStream" on "MediaSource": The MediaSource‘s readyState is not “open”.
  3. Exceeding the buffering quota
  4. 4.22 mov, mp4, ismv
  5. JavaScript Promise 全介紹